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
+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);});
|
||||
|
||||
Reference in new issue
Block a user