feat: implement approved Hope primary conversation shell
This commit is contained in:
1 parent
2be5725fbd
commit
cd7d7d6251
4 files changed
+1192
-1747
No files matched your search
+30
-27
@@ -73,6 +73,7 @@ def test_chat_crud_replay_safe_markdown_and_preserved_branches():
|
|||||||
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
page.get_by_role('button',name='A saved thought',exact=True).click()
|
page.get_by_role('button',name='A saved thought',exact=True).click()
|
||||||
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
|
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
|
||||||
|
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
page.locator('#pinConversation').click(); expect(page.locator('#pinConversation')).to_have_text('Unpin')
|
page.locator('#pinConversation').click(); expect(page.locator('#pinConversation')).to_have_text('Unpin')
|
||||||
page.locator('#primaryInput').fill('A new question'); page.locator('#primarySend').click()
|
page.locator('#primaryInput').fill('A new question'); page.locator('#primarySend').click()
|
||||||
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
|
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
|
||||||
@@ -90,11 +91,12 @@ def test_chat_crud_replay_safe_markdown_and_preserved_branches():
|
|||||||
expect(page.locator('#chatTitle')).to_have_text('New branch')
|
expect(page.locator('#chatTitle')).to_have_text('New branch')
|
||||||
expect(page.locator('#chatTimeline')).to_contain_text('Revised question')
|
expect(page.locator('#chatTimeline')).to_contain_text('Revised question')
|
||||||
assert messages['c1'][0]['content'] == 'Original question'
|
assert messages['c1'][0]['content'] == 'Original question'
|
||||||
page.locator('#openPreferences').click(); page.locator('#paletteSelect').select_option('ocean')
|
page.locator('#openPreferences').click(); expect(page.locator('#preferencesMessage')).to_have_text(''); page.locator('#paletteSelect').select_option('ocean')
|
||||||
page.get_by_role('button',name='Save appearance').click(); expect(page.locator('html')).to_have_attribute('data-palette','ocean')
|
page.get_by_role('button',name='Save appearance').click(); expect(page.locator('html')).to_have_attribute('data-palette','ocean')
|
||||||
page.locator('#closePreferences').click(); page.screenshot(path='/tmp/hope-chat-desktop.png')
|
page.locator('#settingsClose').click(); page.screenshot(path='/tmp/hope-chat-desktop.png')
|
||||||
|
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
page.locator('#deleteConversation').click(); page.locator('#chatActionSave').click()
|
page.locator('#deleteConversation').click(); page.locator('#chatActionSave').click()
|
||||||
expect(page.locator('#chatTitle')).to_have_text('New chat'); assert 'branch' not in conversations
|
expect(page.locator('#chatTitle')).to_have_text('New conversation'); assert 'branch' not in conversations
|
||||||
assert errors == []
|
assert errors == []
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
||||||
@@ -106,17 +108,9 @@ def test_mobile_drawer_keyboard_voice_and_logout():
|
|||||||
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
page.locator('#drawerOpen').click(); expect(page.locator('#drawerOpen')).to_have_attribute('aria-expanded','true')
|
page.locator('#drawerOpen').click(); expect(page.locator('#drawerOpen')).to_have_attribute('aria-expanded','true')
|
||||||
page.keyboard.press('Escape'); expect(page.locator('#drawerOpen')).to_be_focused()
|
page.keyboard.press('Escape'); expect(page.locator('#drawerOpen')).to_be_focused()
|
||||||
page.locator('#drawerOpen').click(); page.locator('#openVoice').click()
|
page.locator('#drawerOpen').click(); page.locator('#accountMenuBtn').click()
|
||||||
expect(page.locator('#startBtn')).to_be_visible(); expect(page.locator('#settingsBtn')).to_be_visible()
|
expect(page.locator('#settingsDialog')).to_be_visible()
|
||||||
page.locator('#backToChat').click(); expect(page.locator('#primaryChat')).to_be_visible()
|
page.locator('#logoutBtn').click()
|
||||||
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))")
|
|
||||||
expect(page.locator('#primaryInput')).to_be_disabled()
|
|
||||||
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:false}}))")
|
|
||||||
expect(page.locator('#primaryInput')).to_be_enabled()
|
|
||||||
page.wait_for_function("document.getElementById('chatSidebar').getBoundingClientRect().right <= 0")
|
|
||||||
page.screenshot(path='/tmp/hope-chat-mobile.png')
|
|
||||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
|
||||||
page.locator('#drawerOpen').click(); page.locator('#openVoice').click(); page.locator('#logoutBtn').click()
|
|
||||||
expect(page.locator('#loginGate')).to_be_visible()
|
expect(page.locator('#loginGate')).to_be_visible()
|
||||||
assert 'Original question' not in page.locator('#chatTimeline').inner_text()
|
assert 'Original question' not in page.locator('#chatTimeline').inner_text()
|
||||||
browser.close()
|
browser.close()
|
||||||
@@ -138,12 +132,12 @@ def test_archived_move_sync_conflict_and_mobile_actions():
|
|||||||
page.evaluate("document.dispatchEvent(new Event('visibilitychange'))")
|
page.evaluate("document.dispatchEvent(new Event('visibilitychange'))")
|
||||||
expect(page.locator('#chatTitle')).to_have_text('Changed elsewhere')
|
expect(page.locator('#chatTitle')).to_have_text('Changed elsewhere')
|
||||||
page.locator('#conversationMenu').click(); page.locator('#archiveConversation').click(); page.locator('#chatActionSave').click()
|
page.locator('#conversationMenu').click(); page.locator('#archiveConversation').click(); page.locator('#chatActionSave').click()
|
||||||
expect(page.locator('#chatTitle')).to_have_text('New chat')
|
expect(page.locator('#chatTitle')).to_have_text('New conversation')
|
||||||
page.locator('#drawerOpen').click(); page.locator('#showArchived').click()
|
page.locator('#drawerOpen').click(); page.locator('#showArchived').click()
|
||||||
page.get_by_role('button',name='Changed elsewhere',exact=True).click()
|
page.get_by_role('button',name='Changed elsewhere',exact=True).click()
|
||||||
page.locator('#conversationMenu').click(); expect(page.locator('#archiveConversation')).to_have_text('Unarchive')
|
page.locator('#conversationMenu').click(); expect(page.locator('#archiveConversation')).to_have_text('Unarchive')
|
||||||
page.locator('#archiveConversation').click(); page.locator('#chatActionSave').click()
|
page.locator('#archiveConversation').click(); page.locator('#chatActionSave').click()
|
||||||
expect(page.locator('#chatTitle')).to_have_text('New chat')
|
expect(page.locator('#chatTitle')).to_have_text('New conversation')
|
||||||
assert conversations['c1']['archived'] is False
|
assert conversations['c1']['archived'] is False
|
||||||
# A stale optimistic write refreshes rather than overwriting another client's title.
|
# A stale optimistic write refreshes rather than overwriting another client's title.
|
||||||
page.locator('#drawerOpen').click(); page.locator('#showArchived').click()
|
page.locator('#drawerOpen').click(); page.locator('#showArchived').click()
|
||||||
@@ -154,7 +148,8 @@ def test_archived_move_sync_conflict_and_mobile_actions():
|
|||||||
route.fulfill(status=409,json={'error':'Stale revision'})
|
route.fulfill(status=409,json={'error':'Stale revision'})
|
||||||
else: route.fallback()
|
else: route.fallback()
|
||||||
page.route('https://hope.test/api/conversations/c1', conflict)
|
page.route('https://hope.test/api/conversations/c1', conflict)
|
||||||
page.locator('#conversationMenu').click(); page.locator('#pinConversation').click()
|
page.locator('#conversationMenu').click(); page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
|
page.locator('#pinConversation').click()
|
||||||
expect(page.locator('#chatTitle')).to_have_text('Newest revision')
|
expect(page.locator('#chatTitle')).to_have_text('Newest revision')
|
||||||
expect(page.locator('#chatNotice')).to_contain_text('Conversation refreshed')
|
expect(page.locator('#chatNotice')).to_contain_text('Conversation refreshed')
|
||||||
browser.close()
|
browser.close()
|
||||||
@@ -180,7 +175,7 @@ def test_voice_history_refresh_keyboard_focus_and_large_text():
|
|||||||
page.screenshot(path='/tmp/hope-chat-opal-desktop.png')
|
page.screenshot(path='/tmp/hope-chat-opal-desktop.png')
|
||||||
page.keyboard.press('Control+k'); expect(page.locator('#conversationSearch')).to_be_focused()
|
page.keyboard.press('Control+k'); expect(page.locator('#conversationSearch')).to_be_focused()
|
||||||
page.keyboard.press('Escape')
|
page.keyboard.press('Escape')
|
||||||
page.locator('#openPreferences').click(); expect(page.locator('#preferencesDialog')).to_be_visible()
|
page.locator('#openPreferences').click(); expect(page.locator('#settingsDialog')).to_be_visible()
|
||||||
page.keyboard.press('Escape'); expect(page.locator('#openPreferences')).to_be_focused()
|
page.keyboard.press('Escape'); expect(page.locator('#openPreferences')).to_be_focused()
|
||||||
# Native controls remain usable at large text and narrow effective viewport.
|
# Native controls remain usable at large text and narrow effective viewport.
|
||||||
page.set_viewport_size({'width':640,'height':900})
|
page.set_viewport_size({'width':640,'height':900})
|
||||||
@@ -202,8 +197,10 @@ def test_qualified_research_and_private_export():
|
|||||||
page.locator('#primaryInput').fill('Research this topic'); page.locator('#primarySend').click()
|
page.locator('#primaryInput').fill('Research this topic'); page.locator('#primarySend').click()
|
||||||
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
|
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
|
||||||
assert next(body for method,path,body in calls if method == 'POST' and path.endswith('/messages'))['mode'] == 'research'
|
assert next(body for method,path,body in calls if method == 'POST' and path.endswith('/messages'))['mode'] == 'research'
|
||||||
|
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
with page.expect_download() as info: page.locator('#exportMarkdown').click()
|
with page.expect_download() as info: page.locator('#exportMarkdown').click()
|
||||||
assert info.value.suggested_filename.endswith('.md')
|
assert info.value.suggested_filename.endswith('.md')
|
||||||
|
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
with page.expect_download() as info: page.locator('#exportJSON').click()
|
with page.expect_download() as info: page.locator('#exportJSON').click()
|
||||||
assert info.value.suggested_filename.endswith('.json')
|
assert info.value.suggested_filename.endswith('.json')
|
||||||
assert all('/api/conversations/' in path for method,path,body in calls if path.endswith('/export'))
|
assert all('/api/conversations/' in path for method,path,body in calls if path.endswith('/export'))
|
||||||
@@ -219,6 +216,7 @@ def test_immediate_pin_after_response_uses_final_server_revision():
|
|||||||
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
|
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
|
||||||
page.locator('#primaryInput').fill('Finish and immediately pin'); page.locator('#primarySend').click()
|
page.locator('#primaryInput').fill('Finish and immediately pin'); page.locator('#primarySend').click()
|
||||||
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
|
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
|
||||||
|
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
|
||||||
page.locator('#pinConversation').click()
|
page.locator('#pinConversation').click()
|
||||||
expect(page.locator('#pinConversation')).to_have_text('Unpin')
|
expect(page.locator('#pinConversation')).to_have_text('Unpin')
|
||||||
assert conversations['c1']['pinned'] is True
|
assert conversations['c1']['pinned'] is True
|
||||||
@@ -228,25 +226,30 @@ def test_immediate_pin_after_response_uses_final_server_revision():
|
|||||||
browser.close()
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
def test_realtime_voice_is_prominent_on_desktop_and_mobile():
|
def test_realtime_voice_is_inline_in_same_thread_and_creates_real_conversation():
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
browser = p.chromium.launch(headless=True)
|
browser = p.chromium.launch(headless=True)
|
||||||
for width in [1280,390]:
|
for width in [1440, 390, 320]:
|
||||||
page = browser.new_page(viewport={'width':width,'height':900})
|
page = browser.new_page(viewport={'width':width,'height':900})
|
||||||
|
errors = []; page.on('pageerror', lambda error: errors.append(str(error)))
|
||||||
calls, streams, conversations, messages = fixture(page)
|
calls, streams, conversations, messages = fixture(page)
|
||||||
|
page.add_init_script("window.addEventListener('hope:start-voice', event => {event.stopImmediatePropagation(); window.startedVoice = event.detail.conversation_id; window.dispatchEvent(new CustomEvent('hope:voice-state', {detail:{active:true,conversation_id:event.detail.conversation_id}}));}, true)")
|
||||||
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
||||||
expect(page.get_by_role('button',name='Talk to Hope',exact=True)).to_be_visible()
|
# Replace only voice transport; durable conversation creation still uses real chat.js.
|
||||||
|
|
||||||
|
expect(page.locator('#primaryComposer #talkToHope')).to_be_visible()
|
||||||
|
assert page.locator('.chat-topbar #talkToHope').count() == 0
|
||||||
page.locator('#primaryInput').fill('Keep this draft while I talk')
|
page.locator('#primaryInput').fill('Keep this draft while I talk')
|
||||||
page.locator('#talkToHope').click()
|
page.locator('#talkToHope').click()
|
||||||
expect(page.locator('#legacyVoice')).to_be_visible()
|
expect(page.locator('#legacyVoice')).to_be_visible()
|
||||||
expect(page.locator('#startBtn')).to_be_visible()
|
expect(page.locator('#primaryChat')).to_be_visible()
|
||||||
expect(page.locator('#voiceModeBtn')).to_have_attribute('aria-pressed','true')
|
|
||||||
expect(page.locator('#backToChat')).to_be_visible()
|
|
||||||
page.locator('#backToChat').click()
|
|
||||||
expect(page.locator('#primaryInput')).to_have_value('Keep this draft while I talk')
|
expect(page.locator('#primaryInput')).to_have_value('Keep this draft while I talk')
|
||||||
expect(page.locator('#talkToHope')).to_be_visible()
|
expect(page.locator('#primaryInput')).to_be_enabled()
|
||||||
assert not any(path == '/token' for method,path,body in calls)
|
expect(page.locator('#primarySend')).to_be_disabled()
|
||||||
|
assert page.evaluate('window.startedVoice') == 'c2'
|
||||||
|
assert any(method == 'POST' and path == '/api/conversations' for method,path,body in calls)
|
||||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||||
page.screenshot(path=f'/tmp/hope-talk-visible-{width}.png')
|
page.screenshot(path=f'/tmp/hope-talk-visible-{width}.png')
|
||||||
|
assert not errors
|
||||||
page.close()
|
page.close()
|
||||||
browser.close()
|
browser.close()
|
||||||
+40
-14
@@ -9,14 +9,14 @@
|
|||||||
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', () => Promise.resolve().then(fn).catch(error => notice(error.message))); return b; };
|
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', () => Promise.resolve().then(fn).catch(error => notice(error.message))); return b; };
|
||||||
function controls() {
|
function controls() {
|
||||||
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive;
|
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive;
|
||||||
$('primaryInput').disabled = !ready || state.busy || running; $('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; $('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();
|
$('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim();
|
||||||
$('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy;
|
$('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);
|
||||||
$('conversationActions').hidden = !state.conversation; $('conversationMenu').hidden = !state.conversation;
|
$('talkToHope').disabled = !ready || state.busy || !!state.stream || !terminal(state.run); $('conversationMenu').hidden = !state.conversation;
|
||||||
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
|
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
|
||||||
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
|
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
|
||||||
$('chatTitle').textContent = state.conversation?.title || 'New chat';
|
$('chatTitle').textContent = state.conversation?.title || 'New conversation';
|
||||||
}
|
}
|
||||||
function selected(conversation) {
|
function selected(conversation) {
|
||||||
state.conversation = conversation;
|
state.conversation = conversation;
|
||||||
@@ -36,12 +36,25 @@
|
|||||||
$('drawerOpen').onclick = () => drawer(true); $('drawerClose').onclick = $('drawerBackdrop').onclick = () => drawer(false);
|
$('drawerOpen').onclick = () => drawer(true); $('drawerClose').onclick = $('drawerBackdrop').onclick = () => drawer(false);
|
||||||
document.addEventListener('keydown', event => { if (event.key === 'Escape' && $('chatSidebar').classList.contains('drawer-open')) drawer(false); if ((event.metaKey || event.ctrlKey) && event.key === 'k') {event.preventDefault(); drawer(true); $('conversationSearch').focus();} });
|
document.addEventListener('keydown', event => { if (event.key === 'Escape' && $('chatSidebar').classList.contains('drawer-open')) drawer(false); if ((event.metaKey || event.ctrlKey) && event.key === 'k') {event.preventDefault(); drawer(true); $('conversationSearch').focus();} });
|
||||||
const mobile = matchMedia('(max-width: 760px)');
|
const mobile = matchMedia('(max-width: 760px)');
|
||||||
function adapt() { if (mobile.matches) $('conversationActions').setAttribute('popover', 'auto'); else {$('conversationActions').removeAttribute('popover');}
|
function adapt() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').inert = false; $('drawerBackdrop').hidden = true; } }
|
||||||
$('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();
|
mobile.addEventListener('change', adapt); adapt();
|
||||||
function showVoice() { $('primaryChat').hidden = true; $('legacyVoice').hidden = false; if (mobile.matches) drawer(false); $('voiceModeBtn').click(); $('startBtn').focus(); }
|
async function startVoice() {
|
||||||
$('openVoice').onclick = showVoice; $('talkToHope').onclick = showVoice;
|
if (state.voiceActive) { $('legacyVoice').hidden = false; $('muteBtn').focus(); return; }
|
||||||
$('backToChat').onclick = () => { $('primaryChat').hidden = false; $('legacyVoice').hidden = true; $('primaryInput').focus(); };
|
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();}}
|
||||||
|
}
|
||||||
|
$('talkToHope').onclick = startVoice;
|
||||||
|
$('openVoice').onclick = startVoice;
|
||||||
|
$('backToChat').onclick = () => $('primaryInput').focus();
|
||||||
|
document.querySelectorAll('.lightwell-symbol').forEach(el => {for (let i=0;i<25;i++) el.append(document.createElement('i'));});
|
||||||
|
$('openLibrary').onclick = () => {if (mobile.matches) drawer(false); $('libraryDialog').showModal();};
|
||||||
|
$('closeLibrary').onclick = () => $('libraryDialog').close();
|
||||||
// Markdown uses DOM text nodes only: HTML is always literal; unsafe URLs never become links.
|
// Markdown uses DOM text nodes only: HTML is always literal; unsafe URLs never become links.
|
||||||
function inline(parent, text) {
|
function inline(parent, text) {
|
||||||
const expression = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^\s)]+\))/g;
|
const expression = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^\s)]+\))/g;
|
||||||
@@ -74,7 +87,7 @@
|
|||||||
const timeline = $('chatTimeline'), atBottom = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 100, scroll = timeline.scrollTop;
|
const timeline = $('chatTimeline'), atBottom = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 100, scroll = timeline.scrollTop;
|
||||||
timeline.replaceChildren();
|
timeline.replaceChildren();
|
||||||
if (state.messageCursor) timeline.append(button('Load earlier messages', olderMessages));
|
if (state.messageCursor) timeline.append(button('Load earlier messages', olderMessages));
|
||||||
if (!state.messages.length) { const welcome = document.createElement('div'); welcome.className = 'chat-welcome'; const h = document.createElement('h2'); h.textContent = 'A little space to think.'; const p = document.createElement('p'); p.textContent = 'Ask a question, work through an idea, or start something new.'; welcome.append(h, p); timeline.append(welcome); }
|
if (!state.messages.length) { const welcome = document.createElement('div'); welcome.className = 'chat-welcome'; const mark = document.createElement('span'); mark.className = 'hope-mark'; mark.setAttribute('aria-hidden','true'); const h = document.createElement('h2'); h.textContent = 'A little space for you.'; const p = document.createElement('p'); p.textContent = 'Think out loud, work something through, or make something new. You can type, or talk to Hope.'; welcome.append(mark, h, p); timeline.append(welcome); }
|
||||||
for (const message of state.messages) {
|
for (const message of state.messages) {
|
||||||
const row = document.createElement('article'); row.className = `chat-message ${message.role}`; row.dataset.messageId = message.id;
|
const row = document.createElement('article'); row.className = `chat-message ${message.role}`; row.dataset.messageId = message.id;
|
||||||
const name = document.createElement('h2'); name.textContent = message.role === 'user' ? 'You' : 'Hope'; row.append(name, markdown(message.content));
|
const name = document.createElement('h2'); name.textContent = message.role === 'user' ? 'You' : 'Hope'; row.append(name, markdown(message.content));
|
||||||
@@ -99,13 +112,14 @@
|
|||||||
state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor;
|
state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor;
|
||||||
}
|
}
|
||||||
async function open(id) {
|
async function open(id) {
|
||||||
|
if (state.voiceActive && id !== state.conversation?.id) {notice('End voice before opening another conversation. Your current thread stays here.'); return;}
|
||||||
stopStream(); const epoch = ++state.epoch; state.run = null; state.messages = []; state.pending = null; notice('Loading conversation…'); controls();
|
stopStream(); const epoch = ++state.epoch; state.run = null; state.messages = []; state.pending = null; notice('Loading conversation…'); controls();
|
||||||
const data = await api('/api/conversations/' + enc(id)); if (epoch !== state.epoch) return;
|
const data = await api('/api/conversations/' + enc(id)); if (epoch !== state.epoch) return;
|
||||||
selected(data.conversation); state.messages = data.messages || []; state.messageCursor = data.next_cursor; state.run = (data.runs || []).find(r => !terminal(r)) || null; render(); notice(''); if (mobile.matches) drawer(false); if (state.run) stream(state.run); await list();
|
selected(data.conversation); state.messages = data.messages || []; state.messageCursor = data.next_cursor; state.run = (data.runs || []).find(r => !terminal(r)) || null; render(); notice(''); if (mobile.matches) drawer(false); if (state.run) stream(state.run); await list();
|
||||||
}
|
}
|
||||||
async function olderMessages() {const epoch = state.epoch, timeline = $('chatTimeline'), height = timeline.scrollHeight; const data = await api('/api/conversations/' + enc(state.conversation.id) + '?' + new URLSearchParams({cursor: state.messageCursor, limit: '50'})); if (epoch !== state.epoch) return; const ids = new Set(state.messages.map(m => m.id)); state.messages = (data.messages || []).filter(m => !ids.has(m.id)).concat(state.messages); state.messageCursor = data.next_cursor; render(); timeline.scrollTop += timeline.scrollHeight - height;}
|
async function olderMessages() {const epoch = state.epoch, timeline = $('chatTimeline'), height = timeline.scrollHeight; const data = await api('/api/conversations/' + enc(state.conversation.id) + '?' + new URLSearchParams({cursor: state.messageCursor, limit: '50'})); if (epoch !== state.epoch) return; const ids = new Set(state.messages.map(m => m.id)); state.messages = (data.messages || []).filter(m => !ids.has(m.id)).concat(state.messages); state.messageCursor = data.next_cursor; render(); timeline.scrollTop += timeline.scrollHeight - height;}
|
||||||
function reset() { stopStream(); ++state.epoch; state.messages = []; state.messageCursor = null; state.run = null; state.pending = null; state.busy = false; selected(null); render(); notice(''); $('primaryInput').value = ''; controls(); }
|
function reset() { stopStream(); ++state.epoch; state.messages = []; state.messageCursor = null; state.run = null; state.pending = null; state.busy = false; selected(null); render(); notice(''); $('primaryInput').value = ''; controls(); }
|
||||||
$('newConversation').onclick = () => {reset(); if (mobile.matches) drawer(false); $('primaryInput').focus();};
|
$('newConversation').onclick = () => {if (state.voiceActive) {notice('End voice before starting a new conversation.'); return;} reset(); if (mobile.matches) drawer(false); $('primaryInput').focus();};
|
||||||
$('conversationSearch').oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => list().catch(e => notice(e.message)), 200); };
|
$('conversationSearch').oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => list().catch(e => notice(e.message)), 200); };
|
||||||
$('showArchived').onclick = () => {state.archived = !state.archived; $('showArchived').setAttribute('aria-pressed', String(state.archived)); $('recentHeading').textContent = state.archived ? 'Archived' : 'Recent'; list().catch(e => notice(e.message));};
|
$('showArchived').onclick = () => {state.archived = !state.archived; $('showArchived').setAttribute('aria-pressed', String(state.archived)); $('recentHeading').textContent = state.archived ? 'Archived' : 'Recent'; list().catch(e => notice(e.message));};
|
||||||
$('loadMoreConversations').onclick = () => list(true).catch(e => notice(e.message));
|
$('loadMoreConversations').onclick = () => list(true).catch(e => notice(e.message));
|
||||||
@@ -170,20 +184,32 @@
|
|||||||
$('cancelRun').onclick = async () => {try {const data = await api('/api/runs/' + enc(state.run.id) + '/cancel', 'POST'); state.run = data.run; controls();} catch (e) {notice(e.message);} };
|
$('cancelRun').onclick = async () => {try {const data = await api('/api/runs/' + enc(state.run.id) + '/cancel', 'POST'); state.run = data.run; controls();} catch (e) {notice(e.message);} };
|
||||||
function workspaceRows() {
|
function workspaceRows() {
|
||||||
$('workspaceList').replaceChildren(button('All conversations', () => {state.workspace = null; list();}, 'conversation-link'));
|
$('workspaceList').replaceChildren(button('All conversations', () => {state.workspace = null; list();}, 'conversation-link'));
|
||||||
for (const workspace of state.workspaces) { const row = document.createElement('div'); row.className = 'workspace-row'; row.append(button(workspace.name, () => {state.workspace = workspace.id; notice('Workspace: ' + workspace.name + '. New chats will be created here.'); list();}, 'conversation-link'), button('Instructions', () => workspaceInstructions(workspace)), button('Edit', () => dialog('Rename workspace', workspace.name, async name => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, name}); await workspaces();})), button('Remove', () => dialog('Remove workspace?', '', async () => {await api('/api/workspaces/' + enc(workspace.id), 'DELETE'); if (state.workspace === workspace.id) state.workspace = null; await workspaces(); await list();}, 'Its conversations are kept outside the workspace.', false))); $('workspaceList').append(row); }
|
for (const workspace of state.workspaces) {
|
||||||
|
const row = document.createElement('div'); row.className = 'workspace-row';
|
||||||
|
row.append(button(workspace.name, () => {state.workspace = workspace.id; notice('Workspace: ' + workspace.name + '. New chats will be created here.'); list();}, 'conversation-link'));
|
||||||
|
const menu = document.createElement('details'), summary = document.createElement('summary'), tools = document.createElement('div'); menu.className = 'workspace-menu'; summary.textContent = '•••'; summary.setAttribute('aria-label', workspace.name + ' workspace actions'); tools.className = 'workspace-tools';
|
||||||
|
tools.append(button('Instructions', () => {menu.open = false; workspaceInstructions(workspace);}), button('Rename', () => {menu.open = false; dialog('Rename workspace', workspace.name, async name => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, name}); await workspaces();});}), button('Remove', () => {menu.open = false; dialog('Remove workspace?', '', async () => {await api('/api/workspaces/' + enc(workspace.id), 'DELETE'); if (state.workspace === workspace.id) state.workspace = null; await workspaces(); await list();}, 'Its conversations are kept outside the workspace.', false);}));
|
||||||
|
menu.append(summary, tools); row.append(menu); $('workspaceList').append(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function workspaces() {const epoch = state.epoch, data = await api('/api/workspaces'); if (epoch !== state.epoch) return; state.workspaces = data.workspaces; workspaceRows();}
|
async function workspaces() {const epoch = state.epoch, data = await api('/api/workspaces'); if (epoch !== state.epoch) return; state.workspaces = data.workspaces; workspaceRows();}
|
||||||
function workspaceInstructions(workspace) {dialog('Workspace instructions', workspace.instructions || '', async instructions => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, instructions}); await workspaces();}, 'These instructions guide chats in this workspace. They do not isolate tool access.');}
|
function workspaceInstructions(workspace) {dialog('Workspace instructions', workspace.instructions || '', async instructions => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, instructions}); await workspaces();}, 'These instructions guide chats in this workspace. They do not isolate tool access.');}
|
||||||
$('newWorkspace').onclick = () => dialog('Create workspace', '', async name => {await api('/api/workspaces', 'POST', {name}); await workspaces();}, 'Group related chats. A workspace is not a separate tool sandbox.');
|
$('newWorkspace').onclick = () => dialog('Create workspace', '', async name => {await api('/api/workspaces', 'POST', {name}); await workspaces();}, 'Group related chats. A workspace is not a separate tool sandbox.');
|
||||||
function theme(preferences) {document.documentElement.dataset.palette = preferences.palette; document.documentElement.dataset.appearance = preferences.appearance; $('paletteSelect').value = preferences.palette; $('appearanceSelect').value = preferences.appearance;}
|
function theme(preferences) {document.documentElement.dataset.palette = preferences.palette; document.documentElement.dataset.appearance = preferences.appearance; $('paletteSelect').value = preferences.palette; $('appearanceSelect').value = preferences.appearance;}
|
||||||
$('openPreferences').onclick = async () => {try {const data = await api('/api/preferences'); state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = ''; if (!HopeAuth.user) return; $('preferencesDialog').showModal();} catch(e) {notice(e.message);} };
|
window.HopeAppearance = {load: async () => {
|
||||||
|
const epoch = state.epoch, user = HopeAuth.user?.id, fields = [...$('preferencesForm').querySelectorAll('select, button[type=submit], button:not([type])')];
|
||||||
|
fields.forEach(field => field.disabled = true);
|
||||||
|
try {const data = await api('/api/preferences'); if (epoch !== state.epoch || user !== HopeAuth.user?.id) return; state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = '';}
|
||||||
|
finally {if (epoch === state.epoch && user === HopeAuth.user?.id) fields.forEach(field => field.disabled = false);}
|
||||||
|
}};
|
||||||
|
$('openPreferences').onclick = async () => {try {if (window.HopeSettings) {await HopeSettings.open('appearance'); return;} await HopeAppearance.load(); if (!HopeAuth.user) return; $('preferencesDialog').showModal();} catch(e) {notice(e.message);} };
|
||||||
$('closePreferences').onclick = () => $('preferencesDialog').close();
|
$('closePreferences').onclick = () => $('preferencesDialog').close();
|
||||||
$('preferencesForm').onsubmit = async event => {event.preventDefault(); try {const data = await api('/api/preferences', 'PUT', {revision: state.preferences.revision, palette: $('paletteSelect').value, appearance: $('appearanceSelect').value}); state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = 'Appearance saved.';} catch(e) {$('preferencesMessage').textContent = e.message;} };
|
$('preferencesForm').onsubmit = async event => {event.preventDefault(); try {const data = await api('/api/preferences', 'PUT', {revision: state.preferences.revision, palette: $('paletteSelect').value, appearance: $('appearanceSelect').value}); state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = 'Appearance saved.';} catch(e) {$('preferencesMessage').textContent = e.message;} };
|
||||||
async function refreshSelected(messages = true) {if (!state.conversation) return; const epoch = state.epoch, cid = state.conversation.id; const data = await api('/api/conversations/' + enc(cid)); if (epoch !== state.epoch || cid !== state.conversation?.id) return; selected(data.conversation); if (messages && terminal(state.run)) {const timeline = $('chatTimeline'), scroll = timeline.scrollTop; const incoming = new Map((data.messages || []).map(m => [m.id,m])); state.messages = state.messages.map(m => incoming.get(m.id) || m); for (const m of incoming.values()) if (!state.messages.some(old => old.id === m.id)) state.messages.push(m); render(); timeline.scrollTop = scroll;} }
|
async function refreshSelected(messages = true) {if (!state.conversation) return; const epoch = state.epoch, cid = state.conversation.id; const data = await api('/api/conversations/' + enc(cid)); if (epoch !== state.epoch || cid !== state.conversation?.id) return; selected(data.conversation); if (messages && terminal(state.run)) {const timeline = $('chatTimeline'), scroll = timeline.scrollTop; const incoming = new Map((data.messages || []).map(m => [m.id,m])); state.messages = state.messages.map(m => incoming.get(m.id) || m); for (const m of incoming.values()) if (!state.messages.some(old => old.id === m.id)) state.messages.push(m); render(); timeline.scrollTop = scroll;} }
|
||||||
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) {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;}}
|
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();});
|
document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();});
|
||||||
HopeAuth.onChange((user, changed) => {
|
HopeAuth.onChange((user, changed) => {
|
||||||
if (changed || !user) {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(); theme({palette: 'opal', appearance: 'system'});}
|
if (changed || !user) {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'});}
|
||||||
controls(); if (!user || !changed) return;
|
controls(); if (!user || !changed) return;
|
||||||
const epoch = state.epoch; syncTimer = setInterval(sync, 5000);
|
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);});
|
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);});
|
||||||
|
|||||||
+51
-156
@@ -14,8 +14,7 @@
|
|||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-mode="voice">
|
<body data-mode="voice">
|
||||||
<a class="skip-link" href="#chatTimeline">Skip to conversation</a>
|
<a class="skip-link" href="#chatTimeline">Skip to conversation</a>
|
||||||
|
|
||||||
<section id="loginGate" class="login-gate" aria-labelledby="loginHeading">
|
<section id="loginGate" class="login-gate" aria-labelledby="loginHeading">
|
||||||
<h1 id="loginHeading">Welcome to Hope</h1>
|
<h1 id="loginHeading">Welcome to Hope</h1>
|
||||||
<p>Sign in to your private voice and text companion.</p>
|
<p>Sign in to your private voice and text companion.</p>
|
||||||
@@ -27,134 +26,32 @@
|
|||||||
<p id="loginMessage" role="status" aria-live="polite">Checking your session…</p>
|
<p id="loginMessage" role="status" aria-live="polite">Checking your session…</p>
|
||||||
<button id="retryLogout" class="btn btn-secondary" hidden>Retry sign out</button>
|
<button id="retryLogout" class="btn btn-secondary" hidden>Retry sign out</button>
|
||||||
</section>
|
</section>
|
||||||
<main class="app-shell" id="authenticatedApp" hidden>
|
|
||||||
<aside id="chatSidebar" class="chat-sidebar" aria-label="Conversations"><div class="sidebar-brand"><span class="hope-mark" aria-hidden="true">✳</span><strong>Hope</strong><button id="drawerClose" class="btn btn-ghost" aria-label="Close navigation">×</button></div><button id="newConversation" class="btn new-chat">+ New chat</button><label class="sr-only" for="conversationSearch">Search conversations</label><input id="conversationSearch" type="search" placeholder="Search conversations"><nav aria-label="Chat history"><h2>Pinned</h2><div id="pinnedConversations"></div><div class="nav-heading"><h2>Workspaces</h2><button id="newWorkspace" class="btn btn-ghost" aria-label="Create workspace">+</button></div><div id="workspaceList"></div><div class="nav-heading"><h2 id="recentHeading">Recent</h2><button id="showArchived" class="btn btn-ghost" aria-pressed="false">Archived</button></div><div id="recentConversations"></div><button id="loadMoreConversations" class="btn btn-ghost" hidden>Load more</button></nav><div class="sidebar-footer"><button id="openVoice" class="btn btn-ghost">Voice & account</button><button id="openPreferences" class="btn btn-ghost">Appearance</button></div></aside><button id="drawerBackdrop" class="drawer-backdrop" aria-label="Close navigation" hidden></button>
|
|
||||||
<section id="primaryChat" class="primary-chat" aria-labelledby="chatTitle"><header class="chat-topbar"><button id="drawerOpen" class="btn btn-ghost" aria-label="Open navigation" aria-expanded="false" aria-controls="chatSidebar">☰</button><h1 id="chatTitle">New chat</h1><button id="talkToHope" class="btn talk-trigger" type="button" aria-controls="legacyVoice">Talk to Hope</button><div id="featureActions" class="feature-actions"></div><button id="conversationMenu" class="btn btn-ghost" popovertarget="conversationActions" aria-label="Conversation actions" hidden>•••</button><div id="conversationActions" class="conversation-actions"><button id="pinConversation" class="btn btn-ghost" disabled>Pin</button><button id="renameConversation" class="btn btn-ghost" disabled>Rename</button><button id="exportMarkdown" class="btn btn-ghost">Download Markdown</button><button id="exportJSON" class="btn btn-ghost">Download JSON</button><button id="moveConversation" class="btn btn-ghost" disabled>Move</button><button id="archiveConversation" class="btn btn-ghost" disabled>Archive</button><button id="deleteConversation" class="btn btn-ghost" disabled>Delete</button></div></header><div id="chatNotice" class="chat-notice" role="status" aria-live="polite"></div><div id="chatTimeline" class="chat-timeline" tabindex="-1" aria-label="Messages"><div class="chat-welcome"><span class="hope-mark" aria-hidden="true">✳</span><h2>A little space to think.</h2><p>Ask a question, work through an idea, or start something new.</p></div></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"><label id="researchModeLabel" for="researchMode" hidden>Mode</label><select id="researchMode" hidden><option value="normal">Chat</option><option value="research">Research</option></select><span>Enter to send · Shift + Enter for a new line</span><button id="cancelRun" class="btn" type="button" hidden>Stop response</button><button id="primarySend" class="btn btn-primary" disabled>Send ↑</button></div></form><p class="chat-footnote">Your chats are private to your account. Hope can make mistakes.</p></section><section id="legacyVoice" class="legacy-voice" hidden aria-label="Voice and account settings"><button id="backToChat" class="btn btn-secondary">← Chat</button><p>Live voice controls and account settings.</p> <header class="topbar">
|
|
||||||
<div class="brand">
|
|
||||||
<span class="brand-glyph" aria-hidden="true"><i></i></span>
|
|
||||||
<div>
|
|
||||||
<h1>Hope</h1>
|
|
||||||
<p>Voice and text companion</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="topbar-actions" id="sessionActions" hidden>
|
<main class="app-shell" id="authenticatedApp" hidden>
|
||||||
<div class="mode-switch" role="group" aria-label="Conversation mode">
|
<aside id="chatSidebar" class="chat-sidebar" aria-label="Main navigation">
|
||||||
<button id="voiceModeBtn" class="mode-option active" type="button" aria-pressed="true">
|
<div class="sidebar-brand"><span class="hope-mark" aria-hidden="true"></span><strong>Hope</strong><button id="drawerClose" class="btn btn-icon" aria-label="Close navigation">×</button></div>
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path></svg>
|
<button id="newConversation" class="btn new-chat">+ New conversation</button>
|
||||||
<span>Voice</span>
|
<label class="sr-only" for="conversationSearch">Search conversations</label><input id="conversationSearch" type="search" placeholder="Search conversations">
|
||||||
</button>
|
<nav aria-label="Chat history"><div class="nav-heading"><h2>Workspaces</h2><button id="newWorkspace" class="btn btn-icon" aria-label="Create workspace">+</button></div><div id="workspaceList"></div><h2>Pinned</h2><div id="pinnedConversations"></div><div class="nav-heading"><h2 id="recentHeading">Recent</h2><button id="showArchived" class="btn btn-ghost" aria-pressed="false">Archived</button></div><div id="recentConversations"></div><button id="loadMoreConversations" class="btn btn-ghost" hidden>Load more</button></nav>
|
||||||
<button id="textModeBtn" class="mode-option" type="button" aria-pressed="false">
|
<div class="sidebar-footer" id="sessionActions" hidden><button id="openLibrary" class="btn btn-ghost">▱ Library</button><button id="tasksBtn" class="btn btn-ghost">⌁ Activity <span id="tasksBadge" class="tasks-badge" hidden>0</span></button><button id="accountMenuBtn" data-settings-open="account" class="btn account-trigger"><span class="account-avatar" aria-hidden="true">◦</span><span id="identityChip"><strong id="identityName"></strong><small>Account & settings</small></span><span aria-hidden="true">⌄</span></button><button id="openPreferences" class="btn btn-ghost">Appearance</button></div>
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"></path></svg>
|
</aside><button id="drawerBackdrop" class="drawer-backdrop" aria-label="Close navigation" hidden></button>
|
||||||
<span>Text</span>
|
<section id="primaryChat" class="primary-chat" aria-labelledby="chatTitle">
|
||||||
</button>
|
<header class="chat-topbar"><button id="drawerOpen" class="btn btn-icon" aria-label="Open navigation" aria-expanded="false" aria-controls="chatSidebar">☰</button><div class="chat-heading"><h1 id="chatTitle">New conversation</h1><p>Your private conversation</p></div><span class="privacy-badge">Only you</span><button id="conversationMenu" class="btn btn-icon" popovertarget="conversationActions" aria-label="Conversation actions" hidden>•••</button></header>
|
||||||
</div>
|
<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>
|
||||||
<div class="identity-chip" id="identityChip" aria-label="Current account">
|
<p id="accountStatus" role="status" class="chat-notice"></p><div id="chatNotice" class="chat-notice" role="status" aria-live="polite"></div>
|
||||||
<span class="presence-dot" aria-hidden="true"></span>
|
<section id="legacyVoice" class="inline-voice workspace" hidden aria-labelledby="voiceHeading">
|
||||||
<span>Private to <strong id="identityName"></strong></span>
|
<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>
|
<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>
|
||||||
<button id="tasksBtn" class="btn btn-secondary tasks-trigger" aria-label="Assistant activity" title="Assistant activity">
|
<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>
|
||||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h11M8 12h11M8 18h7"></path><path d="m3 6 1 1 2-2M3 12l1 1 2-2M3 18l1 1 2-2"></path></svg>
|
</section>
|
||||||
<span>Activity</span>
|
<div id="chatTimeline" class="chat-timeline" tabindex="-1" aria-label="Messages"></div><div id="runProgress" class="run-progress" role="status" aria-live="polite"></div>
|
||||||
<span class="tasks-badge" id="tasksBadge" hidden>0</span>
|
<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>
|
||||||
</button>
|
<p class="chat-footnote"><span>Your conversation, private to you.</span><span class="keyhint">Enter to send · Shift + Enter for a new line</span></p>
|
||||||
</div>
|
</section></main>
|
||||||
</header>
|
<dialog id="settingsDialog" class="settings-window" aria-labelledby="settingsTitle"><div class="dialog-head"><div><h2 id="settingsTitle">Settings</h2><p>Your Hope, the way you like it.</p></div><button id="settingsClose" class="btn btn-icon" aria-label="Close settings">×</button></div><div class="settings-layout"><nav class="settings-nav" aria-label="Settings sections"><button class="btn selected" data-settings-target="account">Account</button><button class="btn" data-settings-target="appearance">Appearance</button><button class="btn" data-settings-target="preferences">Voice & model</button><button class="btn" data-settings-target="privacy">Memory & data</button><button class="btn" data-settings-target="security">Security</button><button id="adminBtn" class="btn admin-link" data-settings-target="admin" hidden aria-expanded="false" aria-controls="adminSection">Administration</button></nav><div class="settings-body">
|
||||||
<div class="account-toolbar">
|
<section data-settings-panel="account"><h3>Your account</h3><p>Personal settings belong here, separate from your conversations.</p><div class="account-heading"><strong id="settingsAccountName"></strong><small id="settingsAccountUsername"></small><small id="settingsAccountRole"></small></div><p id="settingsAccountMessage" role="status"></p><p>Your private assistant and saved conversations belong to this account.</p><button id="logoutBtn" class="btn btn-secondary">Sign out</button></section>
|
||||||
<p id="accountStatus" role="status"></p>
|
<section data-settings-panel="appearance" hidden><h3>Appearance</h3><p>One visual identity across text, voice, and your devices.</p><div id="settingsAppearanceContent"></div></section>
|
||||||
<button id="adminBtn" class="btn btn-secondary" hidden aria-expanded="false" aria-controls="adminSection">Manage accounts</button>
|
<section data-settings-panel="preferences" hidden><div id="settingsSheet" class="settings-sheet" hidden inert><div class="sheet-header"><h3>Voice & model</h3><button id="closeSheetBtn" class="btn btn-icon" aria-label="Close voice settings">×</button></div><div class="sheet-content">
|
||||||
<button id="logoutBtn" class="btn btn-secondary">Sign out</button>
|
|
||||||
</div>
|
|
||||||
<section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading">
|
|
||||||
<div class="admin-header"><div><h2 id="adminHeading" tabindex="-1">Accounts</h2><p>Create a private assistant for each person.</p></div><button id="refreshUsers" class="btn btn-secondary">Refresh status</button></div>
|
|
||||||
<p id="adminMessage" role="status">Loading accounts…</p>
|
|
||||||
<div class="table-scroll"><table><caption class="sr-only">Accounts and private assistant provisioning status</caption><thead><tr><th scope="col">Account</th><th scope="col">Role</th><th scope="col">Status</th><th scope="col">Container</th><th scope="col">Error</th><th scope="col">Actions</th></tr></thead><tbody id="usersBody"></tbody></table></div>
|
|
||||||
<h3>Create account</h3>
|
|
||||||
<form id="createUserForm" class="create-user-form account-form">
|
|
||||||
<div><label for="newUsername">Username</label><input id="newUsername" required autocomplete="off" maxlength="64"></div>
|
|
||||||
<div><label for="newDisplayName">Display name</label><input id="newDisplayName" required autocomplete="off" maxlength="120"></div>
|
|
||||||
<div><label for="newPassword">Initial password</label><input id="newPassword" type="password" required minlength="12" autocomplete="new-password"></div>
|
|
||||||
<div><label for="newRole">Role</label><select id="newRole"><option value="user">User</option><option value="admin">Administrator</option></select></div>
|
|
||||||
<button id="createUserSubmit" class="btn btn-primary">Create account</button>
|
|
||||||
</form><p id="createUserMessage" role="status"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="workspace">
|
|
||||||
<section class="voice-console" aria-labelledby="voiceHeading">
|
|
||||||
<div class="console-intro">
|
|
||||||
<h2 id="voiceHeading">I'm here.</h2>
|
|
||||||
<p id="modeDescription">Speak naturally. I’ll listen, remember, and answer in real time.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="orb-stage">
|
|
||||||
<div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true">
|
|
||||||
</div>
|
|
||||||
<div class="orb-labels" aria-live="polite">
|
|
||||||
<span class="state-label" id="stateLabel">Ready when you are</span>
|
|
||||||
<span class="thinking-chip" id="thinkingChip" hidden>Thinking…</span>
|
|
||||||
<span class="barge-hint" id="bargeHint" hidden>Speak to interrupt</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls" aria-label="Conversation controls">
|
|
||||||
<button id="startBtn" class="btn btn-primary">
|
|
||||||
<span class="button-spark" aria-hidden="true"></span>
|
|
||||||
Start conversation
|
|
||||||
</button>
|
|
||||||
<button id="stopBtn" class="btn btn-danger" disabled>End conversation</button>
|
|
||||||
<button id="muteBtn" class="btn btn-icon" aria-label="Mute microphone" title="Mute microphone" disabled>
|
|
||||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="8" y1="22" x2="16" y2="22"></line></svg>
|
|
||||||
</button>
|
|
||||||
<button id="settingsBtn" class="btn btn-icon" aria-label="Hope settings" title="Hope settings">
|
|
||||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10"></path><circle cx="16" cy="7" r="2"></circle><circle cx="8" cy="17" r="2"></circle></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="connection-strip">
|
|
||||||
<div class="status" id="status" role="status">Secure connection ready</div>
|
|
||||||
<div class="mic-meter" id="micLevel" aria-hidden="true"><span class="mic-meter-fill" id="micMeterFill"></span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="privacy-note">
|
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg>
|
|
||||||
<span id="privacyIdentity"></span>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="transcript" id="conversation" aria-labelledby="conversationHeading">
|
|
||||||
<div class="transcript-header">
|
|
||||||
<div>
|
|
||||||
<h2 id="conversationHeading">Conversation</h2>
|
|
||||||
<p id="transcriptModeLabel">Live voice transcript</p>
|
|
||||||
</div>
|
|
||||||
<button id="clearBtn" class="btn btn-ghost" title="Clear conversation">Clear</button>
|
|
||||||
</div>
|
|
||||||
<div id="messages" aria-live="polite"></div>
|
|
||||||
<form class="chat-composer" id="chatComposer" aria-label="Send a text message">
|
|
||||||
<label class="sr-only" for="chatInput">Message Hope</label>
|
|
||||||
<textarea id="chatInput" rows="1" maxlength="2000" placeholder="Message Hope…" autocomplete="off"></textarea>
|
|
||||||
<button id="sendChatBtn" class="send-chat" type="submit" disabled aria-label="Send message">
|
|
||||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m22 2-7 20-4-9-9-4Z"></path><path d="M22 2 11 13"></path></svg>
|
|
||||||
</button>
|
|
||||||
<span class="composer-hint">Enter to send · Shift+Enter for a new line</span>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section> </main>
|
|
||||||
|
|
||||||
<div class="sheet-backdrop" id="sheetBackdrop" hidden></div>
|
|
||||||
<div class="settings-sheet" id="settingsSheet" hidden inert role="dialog" aria-modal="true" aria-label="Hope settings">
|
|
||||||
<div class="sheet-handle" aria-hidden="true"></div>
|
|
||||||
<div class="sheet-header">
|
|
||||||
<div>
|
|
||||||
<h2>Hope’s settings</h2>
|
|
||||||
<p>Your voice preference applies to this conversation.</p>
|
|
||||||
</div>
|
|
||||||
<button id="closeSheetBtn" class="btn btn-icon" aria-label="Close settings">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="sheet-content">
|
|
||||||
<form id="modelSettingsForm" class="account-form model-settings" aria-labelledby="modelSettingsHeading" aria-describedby="modelSettingsScope">
|
<form id="modelSettingsForm" class="account-form model-settings" aria-labelledby="modelSettingsHeading" aria-describedby="modelSettingsScope">
|
||||||
<h3 id="modelSettingsHeading">Model and thinking</h3>
|
<h3 id="modelSettingsHeading">Model and thinking</h3>
|
||||||
<p id="modelSettingsScope" class="model-settings-note">Saved for your account. Applies to your next request, including in an active conversation, without interrupting it.</p>
|
<p id="modelSettingsScope" class="model-settings-note">Saved for your account. Applies to your next request, including in an active conversation, without interrupting it.</p>
|
||||||
@@ -179,37 +76,35 @@
|
|||||||
<p id="voiceCatalogStatus" class="model-settings-note" role="status" aria-live="polite">Ava Dragon HD Omni is the recommended female default.</p>
|
<p id="voiceCatalogStatus" class="model-settings-note" role="status" aria-live="polite">Ava Dragon HD Omni is the recommended female default.</p>
|
||||||
<button id="retryVoices" type="button" class="btn btn-secondary" hidden>Retry voice catalog</button>
|
<button id="retryVoices" type="button" class="btn btn-secondary" hidden>Retry voice catalog</button>
|
||||||
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="English voices"></div>
|
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="English voices"></div>
|
||||||
</div>
|
</div></div></section>
|
||||||
</div>
|
<section data-settings-panel="privacy" hidden><h3>Memory & data</h3><p id="privacyIdentity"></p><p>Memory controls are available only when supported by your private assistant. Conversation deletion does not erase account memory.</p><p id="settingsMemoryStatus" role="status"></p><div id="privacyMemory"></div><button id="settingsOpenLibrary" class="btn btn-secondary">Open Library</button></section>
|
||||||
|
<section data-settings-panel="security" hidden><h3>Security</h3><p>Hope uses a secure cookie session. Sign out when you leave a shared device.</p><p>Password resets are managed by an administrator and revoke existing sessions.</p></section>
|
||||||
|
<section data-settings-panel="admin" hidden> <section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading">
|
||||||
|
<div class="admin-header"><div><h2 id="adminHeading" tabindex="-1">Accounts</h2><p>Create a private assistant for each person.</p></div><button id="refreshUsers" class="btn btn-secondary">Refresh status</button></div>
|
||||||
|
<p id="adminMessage" role="status">Loading accounts…</p>
|
||||||
|
<div class="table-scroll"><table><caption class="sr-only">Accounts and private assistant provisioning status</caption><thead><tr><th scope="col">Account</th><th scope="col">Role</th><th scope="col">Status</th><th scope="col">Container</th><th scope="col">Error</th><th scope="col">Actions</th></tr></thead><tbody id="usersBody"></tbody></table></div>
|
||||||
|
<h3>Create account</h3>
|
||||||
|
<form id="createUserForm" class="create-user-form account-form">
|
||||||
|
<div><label for="newUsername">Username</label><input id="newUsername" required autocomplete="off" maxlength="64"></div>
|
||||||
|
<div><label for="newDisplayName">Display name</label><input id="newDisplayName" required autocomplete="off" maxlength="120"></div>
|
||||||
|
<div><label for="newPassword">Initial password</label><input id="newPassword" type="password" required minlength="12" autocomplete="new-password"></div>
|
||||||
|
<div><label for="newRole">Role</label><select id="newRole"><option value="user">User</option><option value="admin">Administrator</option></select></div>
|
||||||
|
<button id="createUserSubmit" class="btn btn-primary">Create account</button>
|
||||||
|
</form><p id="createUserMessage" role="status"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity">
|
</section>
|
||||||
<div class="tasks-header">
|
</div></div></dialog>
|
||||||
<div>
|
<div id="sheetBackdrop" class="sheet-backdrop" hidden></div>
|
||||||
<h2>Assistant activity</h2>
|
<dialog id="libraryDialog" aria-labelledby="libraryTitle"><div class="dialog-head"><h2 id="libraryTitle">Library</h2><button id="closeLibrary" class="btn btn-icon" aria-label="Close Library">×</button></div><p>Files and saved work, private to your account.</p><div id="libraryContent"></div></dialog>
|
||||||
<p>Progress from your private assistant.</p>
|
<div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity"><div class="tasks-header"><div><h2>Activity</h2><p>Progress from your private assistant.</p></div><button id="closeTasksBtn" class="btn btn-icon" aria-label="Close activity">×</button></div><div class="tasks-list" id="tasksList"><div class="tasks-empty"><strong>No activity yet</strong><span>Progress appears here while Hope works. Replies stay in your conversation.</span></div></div><div id="activityContent"></div></div>
|
||||||
</div>
|
<div hidden aria-hidden="true"><button id="voiceModeBtn" type="button">Voice</button><button id="textModeBtn" type="button">Text</button><button id="clearBtn" type="button">Clear</button><button id="openVoice" type="button">Voice</button><button id="backToChat" type="button">Back to chat</button><form id="chatComposer"><textarea id="chatInput"></textarea><button id="sendChatBtn" disabled>Send</button></form></div>
|
||||||
<button id="closeTasksBtn" class="btn btn-icon" aria-label="Close activity">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="tasks-list" id="tasksList">
|
|
||||||
<div class="tasks-empty">
|
|
||||||
<strong>No activity yet</strong>
|
|
||||||
<span>Progress appears here while Hope works. Replies stay in your conversation.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="livekit-client.umd.js"></script>
|
|
||||||
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
|
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
|
||||||
<form id="accountActionForm" class="account-form"><h2 id="accountDialogTitle"></h2><p id="accountDialogDescription"></p>
|
<form id="accountActionForm" class="account-form"><h2 id="accountDialogTitle"></h2><p id="accountDialogDescription"></p>
|
||||||
<div id="resetPasswordField"><label for="resetPassword">New password</label><input id="resetPassword" type="password" minlength="12" autocomplete="new-password"></div>
|
<div id="resetPasswordField"><label for="resetPassword">New password</label><input id="resetPassword" type="password" minlength="12" autocomplete="new-password"></div>
|
||||||
<p id="accountDialogMessage" role="status"></p><div class="dialog-actions"><button id="cancelAccountAction" type="button" class="btn btn-secondary">Cancel</button><button id="confirmAccountAction" class="btn btn-danger">Confirm</button></div>
|
<p id="accountDialogMessage" role="status"></p><div class="dialog-actions"><button id="cancelAccountAction" type="button" class="btn btn-secondary">Cancel</button><button id="confirmAccountAction" class="btn btn-danger">Confirm</button></div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="chatActionDialog" aria-labelledby="chatActionTitle"><form id="chatActionForm" class="account-form"><h2 id="chatActionTitle"></h2><p id="chatActionDescription"></p><label id="chatActionLabel" for="chatActionValue">Name</label><textarea id="chatActionValue" rows="3" required></textarea><label id="chatWorkspaceLabel" for="chatWorkspaceSelect" hidden>Workspace</label><select id="chatWorkspaceSelect" hidden></select><p id="chatActionError" role="status"></p><div class="dialog-actions"><button id="chatActionCancel" type="button" class="btn">Cancel</button><button id="chatActionSave" class="btn btn-primary">Save</button></div></form></dialog><dialog id="preferencesDialog" aria-labelledby="preferencesTitle"><h2 id="preferencesTitle">Appearance</h2><form id="preferencesForm" class="account-form"><label for="paletteSelect">Palette</label><select id="paletteSelect"><option value="opal">Opal</option><option value="graphite">Graphite</option><option value="ocean">Ocean</option><option value="sage">Sage</option><option value="lavender">Lavender</option></select><label for="appearanceSelect">Appearance</label><select id="appearanceSelect"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><p id="preferencesMessage" role="status"></p><div class="dialog-actions"><button id="closePreferences" class="btn" type="button">Close</button><button class="btn btn-primary">Save appearance</button></div></form></dialog><script src="auth.js"></script>
|
<dialog id="chatActionDialog" aria-labelledby="chatActionTitle"><form id="chatActionForm" class="account-form"><h2 id="chatActionTitle"></h2><p id="chatActionDescription"></p><label id="chatActionLabel" for="chatActionValue">Name</label><textarea id="chatActionValue" rows="3" required></textarea><label id="chatWorkspaceLabel" for="chatWorkspaceSelect" hidden>Workspace</label><select id="chatWorkspaceSelect" hidden></select><p id="chatActionError" role="status"></p><div class="dialog-actions"><button id="chatActionCancel" type="button" class="btn">Cancel</button><button id="chatActionSave" class="btn btn-primary">Save</button></div></form></dialog><dialog id="preferencesDialog" aria-labelledby="preferencesTitle"><h2 id="preferencesTitle">Appearance</h2><form id="preferencesForm" class="account-form"><label for="paletteSelect">Palette</label><select id="paletteSelect"><option value="opal">Opal</option><option value="graphite">Graphite</option><option value="ocean">Ocean</option><option value="sage">Sage</option><option value="lavender">Lavender</option></select><label for="appearanceSelect">Appearance</label><select id="appearanceSelect"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><p id="preferencesMessage" role="status"></p><div class="dialog-actions"><button id="closePreferences" class="btn" type="button">Close</button><button class="btn btn-primary">Save appearance</button></div></form></dialog>
|
||||||
<script src="app.js"></script>
|
<script src="livekit-client.umd.js"></script><script src="auth.js"></script><script src="app.js"></script><script src="chat.js"></script><script src="features.js"></script><script src="settings.js"></script>
|
||||||
<script src="chat.js"></script>
|
</body></html>
|
||||||
<script src="features.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+1071
-1550
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user