fix: give realtime voice a dedicated conversation view
Rebuild & Deploy / rebuild (push) Successful in 3m7s
Rebuild & Deploy / rebuild (push) Successful in 3m7s
This commit is contained in:
1 parent
257ab48f0f
commit
8905dc2c2a
9 files changed
+302
-101
No files matched your search
+32
-29
@@ -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();
|
||||
}
|
||||
|
||||
+49
-6
@@ -12,10 +12,10 @@
|
||||
const button = (text, fn, cls = 'btn btn-ghost') => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.addEventListener('click', () => {const epoch = accountEpoch; Promise.resolve().then(fn).catch(error => {if (epoch === accountEpoch) notice(error.message);});}); return b; };
|
||||
function controls() {
|
||||
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive;
|
||||
$('primaryInput').disabled = !ready || state.busy || !terminal(state.run) || !!state.stream; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy));
|
||||
$('primaryInput').disabled = !ready || state.busy || !terminal(state.run) || !!state.stream || state.voiceActive; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy));
|
||||
$('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim();
|
||||
$('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy;
|
||||
for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy || !!state.stream || !terminal(state.run);
|
||||
for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy || !!state.stream || !terminal(state.run) || state.voiceActive;
|
||||
$('talkToHope').disabled = !ready || state.busy || !!state.stream || !terminal(state.run); $('conversationMenu').hidden = !state.conversation;
|
||||
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
|
||||
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
|
||||
@@ -26,7 +26,51 @@
|
||||
window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}}));
|
||||
controls();
|
||||
}
|
||||
window.addEventListener('hope:voice-state', event => {const wasActive = state.voiceActive; state.voiceActive = !!event.detail?.active; controls(); if (state.voiceActive) notice('Voice is active. End the voice conversation before sending text.'); else {if (wasActive) notice(''); if (wasActive && HopeAuth.user && state.conversation) refreshSelected().then(() => {notice('Voice conversation saved.'); list();}).catch(error => notice(error.message));}});
|
||||
let voiceReturnFocus = null;
|
||||
function voiceSurface(active) {
|
||||
const wasActive = $('primaryChat').dataset.voiceActive === 'true';
|
||||
if (active && !wasActive) {
|
||||
voiceReturnFocus = document.activeElement;
|
||||
const history = $('voiceHistory'); history.replaceChildren();
|
||||
// Snapshot only this selected durable thread; live turns are separate until refreshed on stop.
|
||||
for (const message of state.messages) {
|
||||
const row = document.createElement('article'); row.className = `chat-message ${message.role}`;
|
||||
const name = document.createElement('h2'); name.textContent = message.role === 'user' ? 'You' : 'Hope';
|
||||
row.append(name, markdown(message.content)); history.append(row);
|
||||
}
|
||||
}
|
||||
$('primaryChat').dataset.voiceActive = String(active);
|
||||
$('legacyVoice').hidden = !active;
|
||||
for (const id of ['chatTimeline', 'primaryComposer', 'runProgress']) {$(id).hidden = active; $(id).inert = active;}
|
||||
document.querySelector('.chat-footnote').hidden = active;
|
||||
document.querySelector('.skip-link').href = active ? '#conversation' : '#chatTimeline';
|
||||
if (active && !wasActive) {
|
||||
if ($('chatSidebar').classList.contains('drawer-open')) drawer(false);
|
||||
$('conversationActions').matches(':popover-open') && $('conversationActions').hidePopover();
|
||||
$('stopBtn').disabled = false; $('stopBtn').focus({preventScroll:true});
|
||||
$('conversation').scrollTop = $('conversation').scrollHeight;
|
||||
} else if (!active && wasActive && HopeAuth.user) {
|
||||
if ($('legacyVoice').contains(document.activeElement) || document.activeElement === document.body) {
|
||||
const target = voiceReturnFocus?.isConnected && voiceReturnFocus.getClientRects().length && !voiceReturnFocus.closest('[inert]') && !voiceReturnFocus.disabled ? voiceReturnFocus : $('talkToHope');
|
||||
target.focus({preventScroll:true});
|
||||
}
|
||||
voiceReturnFocus = null;
|
||||
}
|
||||
}
|
||||
window.addEventListener('hope:voice-state', event => {
|
||||
const wasActive = state.voiceActive; state.voiceActive = !!event.detail?.active;
|
||||
voiceSurface(state.voiceActive); controls();
|
||||
if (state.voiceActive) notice('');
|
||||
else if (wasActive) {
|
||||
const epoch = state.epoch, cid = state.conversation?.id, before = new Set(state.messages.map(m => m.id));
|
||||
notice(event.detail?.error ? 'Could not start voice: ' + event.detail.error : 'Voice ended. Refreshing conversation…');
|
||||
if (HopeAuth.user && cid) refreshSelected().then(() => {
|
||||
if (epoch !== state.epoch || cid !== state.conversation?.id || state.voiceActive) return;
|
||||
if (!event.detail?.error) notice(state.messages.some(m => !before.has(m.id)) ? 'Voice conversation saved. History refreshed.' : 'Voice ended. History refreshed.');
|
||||
return list();
|
||||
}).catch(error => {if (epoch === state.epoch && !state.voiceActive) notice('Could not refresh voice history: ' + error.message);});
|
||||
}
|
||||
});
|
||||
window.addEventListener('hope:attachments-changed', controls);
|
||||
window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}};
|
||||
function stopStream() { state.stream?.abort(); state.stream = null; }
|
||||
@@ -42,13 +86,12 @@
|
||||
function adapt() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').inert = false; $('drawerBackdrop').hidden = true; } }
|
||||
mobile.addEventListener('change', adapt); adapt();
|
||||
async function startVoice() {
|
||||
if (state.voiceActive) { $('legacyVoice').hidden = false; $('muteBtn').focus(); return; }
|
||||
if (state.voiceActive) { $('stopBtn').focus(); return; }
|
||||
if (state.busy || state.stream || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return;
|
||||
const epoch = state.epoch; state.busy = true; controls();
|
||||
try {
|
||||
if (!state.conversation) {const data = await api('/api/conversations', 'POST', {title: 'Voice conversation', ...(state.workspace ? {workspace_id: state.workspace} : {})}); if (epoch !== state.epoch) return; selected(data.conversation); await list();}
|
||||
if (epoch !== state.epoch) return;
|
||||
$('legacyVoice').hidden = false;
|
||||
window.dispatchEvent(new CustomEvent('hope:start-voice', {detail: {conversation_id: state.conversation.id}}));
|
||||
} catch (error) {if (epoch === state.epoch) notice(error.message);} finally {if (epoch === state.epoch) {state.busy = false; controls();}}
|
||||
}
|
||||
@@ -260,7 +303,7 @@
|
||||
async function sync() {if (syncBusy || !HopeAuth.user || document.hidden) return; syncBusy = true; const epoch = state.epoch; try {const data = await api('/api/sync?after=' + state.syncCursor); if (epoch !== state.epoch) return; state.syncCursor = data.cursor; if (!data.changes?.length) return; for (const change of data.changes) {if (change.kind === 'preferences' && change.data && !$('preferencesDialog').open && !(window.HopeSettings?.isOpen && HopeSettings.section === 'appearance')) {state.preferences = change.data; theme(change.data);} if (change.kind === 'conversation' && change.id === state.conversation?.id && change.deleted) {reset(); notice('This conversation was deleted on another device.');}} await Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), refreshSelected()]);} catch (error) {/* A subsequent foreground poll retries; never discard the draft. */} finally {syncBusy = false;}}
|
||||
document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();});
|
||||
HopeAuth.onChange((user, changed) => {
|
||||
if (changed || !user) {++accountEpoch; closeRowMenu(); rowMenu?.remove(); rowMenu = null; sidebarWrites.clear(); $('chatActionSave').disabled = false; clearTimeout(searchTimer); clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; state.research = false; $('researchMode').hidden = true; $('researchModeLabel').hidden = true; $('researchMode').value = 'normal'; reset(); state.workspace = null; state.workspaces = []; state.preferences = null; $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); $('workspaceList').replaceChildren(); $('legacyVoice').hidden = true; $('primaryChat').hidden = false; $('preferencesDialog').close(); $('chatActionDialog').close(); $('libraryDialog').close(); theme({palette: 'opal', appearance: 'system'});}
|
||||
if (changed || !user) {++accountEpoch; closeRowMenu(); rowMenu?.remove(); rowMenu = null; sidebarWrites.clear(); $('chatActionSave').disabled = false; clearTimeout(searchTimer); clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; state.research = false; $('researchMode').hidden = true; $('researchModeLabel').hidden = true; $('researchMode').value = 'normal'; reset(); state.workspace = null; state.workspaces = []; state.preferences = null; state.voiceActive = false; voiceSurface(false); $('voiceHistory').replaceChildren(); $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); $('workspaceList').replaceChildren(); $('legacyVoice').hidden = true; $('primaryChat').hidden = false; $('preferencesDialog').close(); $('chatActionDialog').close(); $('libraryDialog').close(); theme({palette: 'opal', appearance: 'system'});}
|
||||
controls(); if (!user || !changed) return;
|
||||
const epoch = state.epoch; syncTimer = setInterval(sync, 5000);
|
||||
Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), api('/api/preferences').then(data => {if (epoch === state.epoch) {state.preferences = data.preferences; theme(data.preferences);}})]).catch(e => {if (epoch === state.epoch) notice(e.message);});
|
||||
|
||||
+3
-3
@@ -40,9 +40,9 @@
|
||||
<div id="conversationActions" class="conversation-actions" popover="auto"><button id="pinConversation" class="btn btn-ghost" disabled>Pin</button><button id="renameConversation" class="btn btn-ghost" disabled>Rename</button><button id="moveConversation" class="btn btn-ghost" disabled>Move</button><button id="exportMarkdown" class="btn btn-ghost">Download Markdown</button><button id="exportJSON" class="btn btn-ghost">Download JSON</button><button id="archiveConversation" class="btn btn-ghost" disabled>Archive</button><button id="deleteConversation" class="btn btn-ghost" disabled>Delete</button></div>
|
||||
<p id="accountStatus" role="status" class="chat-notice"></p><div id="chatNotice" class="chat-notice" role="status" aria-live="polite"></div>
|
||||
<section id="legacyVoice" class="inline-voice workspace" hidden aria-labelledby="voiceHeading">
|
||||
<div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true"></div><div class="voice-summary"><h2 id="voiceHeading">Realtime conversation</h2><p id="stateLabel" aria-live="polite">Ready when you are</p><p id="modeDescription">Speak naturally. You can interrupt Hope.</p><span id="thinkingChip" hidden>Thinking…</span><span id="bargeHint" hidden>Speak to interrupt</span><div id="status" class="status" role="status">Secure connection ready</div><div class="mic-meter" id="micLevel" aria-hidden="true"><span id="micMeterFill"></span></div></div>
|
||||
<div class="voice-controls"><button id="startBtn" class="btn btn-primary" type="button">Start voice</button><button id="muteBtn" class="btn btn-secondary" aria-label="Mute microphone" title="Mute microphone" disabled>Mute</button><button id="stopBtn" class="btn btn-danger" disabled>End</button><button id="settingsBtn" class="btn btn-ghost" type="button">Voice settings</button></div>
|
||||
<div id="conversation" class="live-transcript"><h3 id="conversationHeading" class="sr-only">Live transcript</h3><span id="transcriptModeLabel" class="sr-only">Live voice transcript</span><div id="messages" aria-live="polite"></div></div>
|
||||
<header class="voice-session-header"><div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true"></div><div class="voice-summary"><h2 id="voiceHeading">Voice conversation</h2><p id="stateLabel" aria-live="polite">Ready when you are</p><p id="modeDescription" class="sr-only">Speak naturally. You can interrupt Hope.</p><span id="thinkingChip" hidden>Thinking…</span><span id="bargeHint" hidden>Speak to interrupt</span><div id="status" class="status" role="status">Secure connection ready</div><div class="mic-meter" id="micLevel" aria-hidden="true"><span id="micMeterFill"></span></div></div><button id="settingsBtn" class="btn btn-ghost" type="button">Settings</button></header>
|
||||
<div id="conversation" class="live-transcript" tabindex="0" role="region" aria-labelledby="conversationHeading"><h3 id="conversationHeading" class="sr-only">Voice transcript</h3><span id="transcriptModeLabel" class="sr-only">Live voice transcript</span><div id="voiceHistory"></div><p id="voiceTranscriptHint" class="voice-transcript-hint">Your live transcript will appear here when you speak.</p><div id="messages" role="log" aria-label="Live voice messages" aria-live="polite" aria-relevant="additions text"></div></div>
|
||||
<div class="voice-controls"><button id="startBtn" class="btn btn-primary" type="button" hidden>Start voice</button><button id="muteBtn" class="btn btn-secondary" aria-label="Mute microphone" title="Mute microphone" aria-pressed="false" disabled>Mute</button><button id="stopBtn" class="btn btn-danger" type="button" disabled>End voice</button></div>
|
||||
</section>
|
||||
<div id="chatTimeline" class="chat-timeline" tabindex="-1" aria-label="Messages"></div><div id="runProgress" class="run-progress" role="status" aria-live="polite"></div>
|
||||
<form id="primaryComposer" class="primary-composer" aria-label="Message Hope"><label class="sr-only" for="primaryInput">Message Hope</label><textarea id="primaryInput" rows="2" placeholder="What’s on your mind?" required disabled></textarea><div id="attachmentPreviews"></div><div class="composer-footer"><div id="featureActions" class="feature-actions"></div><label id="researchModeLabel" for="researchMode" hidden>Mode</label><select id="researchMode" hidden><option value="normal">Standard</option><option value="research">Research</option><option value="deep_research">Deep research</option></select><div class="composer-right"><span>Talk</span><button id="talkToHope" class="btn lightwell-button" type="button" aria-label="Start realtime voice conversation" aria-controls="legacyVoice" disabled><span class="lightwell-symbol" aria-hidden="true"></span></button><button id="cancelRun" class="btn" type="button" hidden>Stop</button><button id="primarySend" class="btn btn-primary send-button" aria-label="Send message" disabled>↑</button></div></div></form>
|
||||
|
||||
+43
-57
@@ -525,30 +525,28 @@ input::placeholder,textarea::placeholder{
|
||||
object-fit:cover;
|
||||
border-radius:6px}
|
||||
|
||||
.inline-voice{
|
||||
flex-shrink:0;
|
||||
display:grid;
|
||||
grid-template-columns:84px minmax(0,1fr) auto;
|
||||
align-items:center;
|
||||
gap:16px;
|
||||
padding:16px 24px;
|
||||
border-bottom:1px solid var(--line);
|
||||
background:var(--hope-persona-surface);
|
||||
max-height:38dvh;
|
||||
overflow:auto}
|
||||
.inline-voice{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:var(--canvas)}
|
||||
.voice-session-header{display:flex;align-items:center;gap:14px;padding:14px max(24px,calc((100% - 740px)/2));border-bottom:1px solid var(--line);background:var(--hope-persona-surface);flex-shrink:0}
|
||||
.voice-session-header .led-wall{width:48px;height:48px;flex-shrink:0}
|
||||
.voice-summary{flex:1;min-width:0;overflow-wrap:anywhere}
|
||||
.primary-chat[data-voice-active="true"]>#chatNotice{display:none}
|
||||
.led-wall{
|
||||
width:84px;
|
||||
height:84px;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(21,1fr);
|
||||
grid-template-rows:repeat(21,1fr);
|
||||
grid-template-columns:repeat(21,minmax(0,1fr));
|
||||
grid-template-rows:repeat(21,minmax(0,1fr));
|
||||
overflow:hidden;
|
||||
place-items:center;
|
||||
padding:4px;
|
||||
color:var(--accent);
|
||||
border-radius:12px}
|
||||
.led-dot{
|
||||
width:var(--sz,3px);
|
||||
height:var(--sz,3px);
|
||||
width:100%;
|
||||
height:100%;
|
||||
max-width:3px;
|
||||
max-height:3px;
|
||||
padding:0;
|
||||
min-width:1px;
|
||||
min-height:1px;
|
||||
border:0;
|
||||
@@ -568,16 +566,8 @@ input::placeholder,textarea::placeholder{
|
||||
line-height:1.6;
|
||||
color:var(--ink-soft);
|
||||
margin-top:4px}
|
||||
.voice-controls{
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:6px;
|
||||
justify-content:flex-end;
|
||||
max-width:240px}
|
||||
.voice-controls .btn{
|
||||
font-size:12px}
|
||||
.voice-controls #startBtn:disabled,.voice-controls #stopBtn:disabled{
|
||||
display:none}
|
||||
.voice-controls{display:flex;gap:12px;justify-content:center;flex-shrink:0;padding:14px 16px max(14px,env(safe-area-inset-bottom));border-top:1px solid var(--line);background:var(--hope-persona-surface)}
|
||||
.voice-controls .btn{min-width:112px}
|
||||
.mic-meter{
|
||||
height:2px;
|
||||
max-width:170px;
|
||||
@@ -588,20 +578,19 @@ input::placeholder,textarea::placeholder{
|
||||
height:100%;
|
||||
width:0;
|
||||
background:var(--accent)}
|
||||
.live-transcript{
|
||||
grid-column:2/-1;
|
||||
font-size:13px;
|
||||
line-height:1.65}
|
||||
.live-transcript #messages:empty{
|
||||
display:none}
|
||||
.message-row{
|
||||
margin:10px 0;
|
||||
overflow-wrap:anywhere}
|
||||
.message-row.user{
|
||||
font-weight:600}
|
||||
.message-row.system{
|
||||
color:var(--ink-soft);
|
||||
font-size:11px}
|
||||
.live-transcript{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;padding:28px max(24px,calc((100% - 740px)/2));scrollbar-gutter:stable;font-size:15px;line-height:1.8}
|
||||
.voice-transcript-hint{color:var(--ink-soft);max-width:74ch;margin:12px 0}
|
||||
#voiceHistory:not(:empty){border-bottom:1px solid var(--line);margin-bottom:24px}
|
||||
#voiceHistory .message-actions{display:none}
|
||||
.message-row{max-width:74ch;margin:0 0 24px;overflow-wrap:anywhere}
|
||||
.message-row:before{content:"Hope";display:block;font-size:13px;font-weight:600;margin-bottom:8px}
|
||||
.message-row.user{margin-left:auto;max-width:85%}
|
||||
.message-row.user:before{content:"You";color:var(--ink-soft)}
|
||||
.message-row.user .bubble{background:var(--hope-selected-wash);padding:14px 18px;border-radius:12px}
|
||||
.message-row .bubble{white-space:pre-wrap}
|
||||
.message-row.partial .bubble{color:var(--ink-soft)}
|
||||
.message-row.system{color:var(--ink-soft);font-size:13px}
|
||||
.message-row.system:before{content:none}
|
||||
.audio-viz{
|
||||
display:none}
|
||||
|
||||
@@ -1085,24 +1074,12 @@ dialog p{
|
||||
#researchMode{
|
||||
max-width:90px;
|
||||
font-size:11px}
|
||||
.inline-voice{
|
||||
grid-template-columns:64px minmax(0,1fr);
|
||||
gap:10px;
|
||||
padding:12px 16px;
|
||||
max-height:40dvh}
|
||||
.led-wall{
|
||||
width:64px;
|
||||
height:64px}
|
||||
.voice-controls{
|
||||
grid-column:1/-1;
|
||||
max-width:none;
|
||||
justify-content:flex-start}
|
||||
.live-transcript{
|
||||
grid-column:1/-1}
|
||||
.voice-summary #stateLabel{
|
||||
font-size:16px}
|
||||
.voice-summary #modeDescription{
|
||||
display:none}
|
||||
.voice-session-header{padding:12px 16px;gap:10px}
|
||||
.voice-session-header .led-wall{width:36px;height:36px}
|
||||
.voice-session-header #settingsBtn{padding:9px 8px;font-size:12px}
|
||||
.voice-summary #stateLabel{font-size:16px}
|
||||
.live-transcript{padding:22px 18px;font-size:14px}
|
||||
.message-row.user{max-width:95%}
|
||||
.settings-window>.dialog-head{
|
||||
padding:18px}
|
||||
.settings-layout{
|
||||
@@ -1164,3 +1141,12 @@ dialog p{
|
||||
gap:4px}
|
||||
}
|
||||
|
||||
|
||||
@media(max-height:480px){
|
||||
.chat-sidebar nav{flex:1 0 auto;min-height:max-content;overflow:visible}
|
||||
.primary-chat[data-voice-active="true"] .chat-topbar{min-height:54px;padding-block:5px}
|
||||
.voice-session-header{padding-block:6px}
|
||||
.voice-summary h2,.voice-summary #bargeHint,.voice-summary #thinkingChip,.mic-meter{display:none}
|
||||
.voice-controls{padding-block:6px}
|
||||
.live-transcript{padding-block:16px}
|
||||
}
|
||||
Reference in new issue
Block a user