feat: add qualified research exports and accessible voice handoff

This commit is contained in:
snowspeeder committed 2026-10-03 08:57:35 -04:00
1 parent 135e0c0da2
commit 08b7e20be8
4 files changed
+74 -16

No files matched your search

+53
View File
@@ -18,6 +18,9 @@ def fixture(page, replay=False):
calls.append((method, path, body))
if path == '/api/auth/me': data = {'user': dict(id='alice', username='alice', role='user', status='ready')}
elif path == '/voices': data = {'voices': []}
elif path == '/api/research': data = {'available':True,'mode':'hermes_tools','reason':''}
elif path.endswith('/export'):
r.fulfill(body='private export',content_type='text/plain'); return
elif path == '/api/capabilities': data = {'version':1,'features':{}}
elif path == '/api/preferences':
if method == 'PUT': preferences.update(body); preferences['revision'] += 1
@@ -150,3 +153,53 @@ def test_archived_move_sync_conflict_and_mobile_actions():
expect(page.locator('#chatTitle')).to_have_text('Newest revision')
expect(page.locator('#chatNotice')).to_contain_text('Conversation refreshed')
browser.close()
def test_voice_history_refresh_keyboard_focus_and_large_text():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':1280,'height':900}, reduced_motion='reduce')
calls, streams, conversations, messages = fixture(page)
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
page.get_by_role('button',name='A saved thought',exact=True).click()
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
page.locator('#primaryInput').fill('Keep my draft')
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true,conversation_id:'c1'}}))")
messages['c1'].append(dict(id='voice1',role='assistant',content='Saved voice reply with [source](https://example.com/report)',status='completed'))
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:false,conversation_id:'c1'}}))")
expect(page.locator('#chatTimeline')).to_contain_text('Saved voice reply')
expect(page.locator('#primaryInput')).to_have_value('Keep my draft')
expect(page.locator('#chatTimeline a')).to_have_attribute('href','https://example.com/report')
expect(page.locator('#chatNotice')).to_contain_text('Voice conversation saved')
page.screenshot(path='/tmp/hope-chat-opal-desktop.png')
page.keyboard.press('Control+k'); expect(page.locator('#conversationSearch')).to_be_focused()
page.keyboard.press('Escape')
page.locator('#openPreferences').click(); expect(page.locator('#preferencesDialog')).to_be_visible()
page.keyboard.press('Escape'); expect(page.locator('#openPreferences')).to_be_focused()
# Native controls remain usable at large text and narrow effective viewport.
page.set_viewport_size({'width':640,'height':900})
page.add_style_tag(content='html {font-size: 200% !important;}')
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.locator('#drawerOpen').click(); page.keyboard.press('Escape')
expect(page.locator('#drawerOpen')).to_be_focused()
assert page.evaluate("getComputedStyle(document.getElementById('drawerOpen')).outlineStyle") != 'none'
browser.close()
def test_qualified_research_and_private_export():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(); calls, streams, conversations, messages = fixture(page)
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
expect(page.locator('#researchMode')).to_be_visible()
page.locator('#researchMode').select_option('research')
page.locator('#primaryInput').fill('Research this topic'); page.locator('#primarySend').click()
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'
with page.expect_download() as info: page.locator('#exportMarkdown').click()
assert info.value.suggested_filename.endswith('.md')
with page.expect_download() as info: page.locator('#exportJSON').click()
assert info.value.suggested_filename.endswith('.json')
assert all('/api/conversations/' in path for method,path,body in calls if path.endswith('/export'))
browser.close()
+17 -15
View File
@@ -2,17 +2,17 @@
(() => {
'use strict';
const $ = id => document.getElementById(id), api = (path, method = 'GET', body, signal) => HopeAuth.request(path, {method, ...(body === undefined ? {} : {body: JSON.stringify(body)}), signal});
const state = {conversation: null, messages: [], run: null, workspaces: [], preferences: null, epoch: 0, stream: null, cursor: null, messageCursor: null, workspace: null, busy: false, voiceActive: false, archived: false, syncCursor: 0, pending: null};
const state = {conversation: null, messages: [], run: null, workspaces: [], preferences: null, epoch: 0, stream: null, cursor: null, messageCursor: null, workspace: null, busy: false, voiceActive: false, archived: false, syncCursor: 0, research: false, pending: null};
let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false;
const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status);
const notice = text => { $('chatNotice').textContent = text; };
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() {
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || state.voiceActive;
$('primaryInput').disabled = !ready || state.busy || running;
$('primaryInput').disabled = !ready || state.busy || running; $('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);
for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || 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;
$('conversationActions').hidden = !state.conversation; $('conversationMenu').hidden = !state.conversation;
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
@@ -23,21 +23,21 @@
window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}}));
controls();
}
window.addEventListener('hope:voice-state', event => {state.voiceActive = !!event.detail?.active; controls(); if (state.voiceActive) notice('Voice is active. End the voice conversation before sending text.'); else notice('');});
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));}});
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; }
function drawer(open) {
$('chatSidebar').classList.toggle('drawer-open', open); $('drawerBackdrop').hidden = !open;
$('drawerOpen').setAttribute('aria-expanded', String(open));
if (matchMedia('(max-width: 760px)').matches) { $('primaryChat').inert = open; $('chatSidebar').inert = !open; }
if (matchMedia('(max-width: 760px)').matches) { $('primaryChat').inert = open; $('legacyVoice').inert = open; $('chatSidebar').inert = !open; }
(open ? $('drawerClose') : $('drawerOpen')).focus();
}
$('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();} });
const mobile = matchMedia('(max-width: 760px)');
function adapt() { if (mobile.matches) $('conversationActions').setAttribute('popover', 'auto'); else {$('conversationActions').removeAttribute('popover');}
$('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').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();
$('openVoice').onclick = () => { $('primaryChat').hidden = true; $('legacyVoice').hidden = false; if (mobile.matches) drawer(false); $('backToChat').focus(); };
$('backToChat').onclick = () => { $('primaryChat').hidden = false; $('legacyVoice').hidden = true; $('primaryInput').focus(); };
@@ -77,7 +77,7 @@
for (const message of state.messages) {
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 actions = document.createElement('div'); actions.className = 'message-actions';
const actions = document.createElement('div'); actions.className = 'message-actions'; actions.setAttribute('aria-label', (message.role === 'user' ? 'Your message' : 'Hope response') + ' actions');
actions.append(button('Copy', () => navigator.clipboard.writeText(message.content)));
if (terminal(state.run)) {
if (message.role === 'user') actions.append(button('Edit in new branch', () => edit(message)));
@@ -117,6 +117,8 @@
$('chatActionForm').onsubmit = async event => {event.preventDefault(); $('chatActionSave').disabled = true; try {await action($('chatActionValue').value.trim()); $('chatActionDialog').close();} catch (e) {$('chatActionError').textContent = e.message;} finally {$('chatActionSave').disabled = false;} };
$('renameConversation').onclick = () => dialog('Rename conversation', state.conversation.title, title => update({title}));
$('archiveConversation').onclick = () => dialog(state.conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', async () => {await update({archived: !state.conversation.archived}); reset(); await list();}, 'The conversation is retained, but leaves Recent.', false);
async function exportChat(format) {const epoch = state.epoch, cid = state.conversation.id; const response = await fetch('/api/conversations/' + enc(cid) + '/export?format=' + format, {credentials:'same-origin'}); if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Download failed (' + response.status + ').'); const blob = await response.blob(); if (epoch !== state.epoch) return; const url = URL.createObjectURL(blob), anchor = document.createElement('a'); anchor.href = url; anchor.download = 'hope-conversation-' + cid.replace(/[^a-zA-Z0-9_-]/g,'') + (format === 'json' ? '.json' : '.md'); anchor.click(); setTimeout(() => URL.revokeObjectURL(url),1000); notice('Conversation downloaded.');}
$('exportMarkdown').onclick = () => exportChat('markdown').catch(e => notice(e.message)); $('exportJSON').onclick = () => exportChat('json').catch(e => notice(e.message));
$('moveConversation').onclick = () => {dialog('Move conversation', '', () => update({workspace_id: $('chatWorkspaceSelect').value || null}), 'Keep the same history in a different workspace.', false); const select = $('chatWorkspaceSelect'); select.replaceChildren(); for (const workspace of [{id:'',name:'No workspace'}, ...state.workspaces]) {const option = document.createElement('option'); option.value = workspace.id; option.textContent = workspace.name; select.append(option);} select.value = state.conversation.workspace_id || ''; select.hidden = false; $('chatWorkspaceLabel').hidden = false;};
$('deleteConversation').onclick = () => dialog('Delete conversation?', '', async () => {await api('/api/conversations/' + enc(state.conversation.id), 'DELETE'); reset(); await list();}, 'This deletes the conversation, not your account memory.', false);
async function branch(message, content, send = false) {const data = await api('/api/conversations/' + enc(state.conversation.id) + '/branch', 'POST', {message_id: message.id, ...(content === undefined ? {} : {content})}); await open(data.conversation.id); if (send) {$('primaryInput').value = content; await submit();} }
@@ -128,8 +130,8 @@
try {
if (!state.conversation) {const data = await api('/api/conversations', 'POST', {title: content.slice(0, 80), ...(state.workspace ? {workspace_id: state.workspace} : {})}); if (epoch !== state.epoch) return; selected(data.conversation);}
// Retry an uncertain POST with the same key rather than creating another turn.
const attachmentIDs = window.HopeFeatures?.attachmentIDs() || [];
if (!state.pending || state.pending.content !== content || JSON.stringify(state.pending.attachment_ids) !== JSON.stringify(attachmentIDs)) state.pending = {content, idempotency_key: crypto.randomUUID(), attachment_ids: attachmentIDs};
const attachmentIDs = window.HopeFeatures?.attachmentIDs() || [], mode = state.research ? $('researchMode').value : 'normal';
if (!state.pending || state.pending.content !== content || state.pending.mode !== mode || JSON.stringify(state.pending.attachment_ids) !== JSON.stringify(attachmentIDs)) state.pending = {content, mode, idempotency_key: crypto.randomUUID(), attachment_ids: attachmentIDs};
const data = await api('/api/conversations/' + enc(state.conversation.id) + '/messages', 'POST', state.pending); if (epoch !== state.epoch) return;
window.HopeFeatures?.clearAfterSend(state.pending.attachment_ids); state.pending = null; $('primaryInput').value = ''; upsert(data.message); state.run = data.run; render(); notice(''); stream(data.run); await list();
} catch (e) {if (epoch === state.epoch) notice(e.message + ' You can retry your message.');} finally {state.busy = false; controls();}
@@ -152,7 +154,7 @@
if (type === 'run' || type === 'done') {state.run = data.run || data; controls();}
if (type === 'progress') $('runProgress').textContent = data.message || '';
if (type === 'approval') approval(data, run.id);
if (type === 'done') {done = true; await reader.cancel(); break;}
if (type === 'done') {notice(state.run?.status === 'cancelled' ? 'Response stopped.' : state.run?.status === 'failed' ? 'Response failed.' : 'Response ready.'); done = true; await reader.cancel(); break;}
}
if (part.done) done = true;
}
@@ -167,7 +169,7 @@
$('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() {
$('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('Instructions', () => workspaceInstructions(workspace))); row.append(button(workspace.name, () => {state.workspace = workspace.id; notice('Workspace: ' + workspace.name + '. New chats will be created here.'); list();}, 'conversation-link'), 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'), 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); }
}
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.');}
@@ -177,12 +179,12 @@
$('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;} };
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(), 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) {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) {clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; 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(); theme({palette: 'opal', appearance: 'system'});}
controls(); if (!user || !changed) return;
const epoch = state.epoch; syncTimer = setInterval(sync, 5000);
Promise.all([list(), workspaces(), 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);});
});
})();
+1 -1
View File
@@ -29,7 +29,7 @@
</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 &amp; 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><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="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"><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">← Back to chat</button><p>Live voice controls and account settings.</p> <header class="topbar">
<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><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">← Back to 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>
+3
View File
@@ -1543,3 +1543,6 @@ dialog h2 {font-size:22px;margin-bottom:16px;}
.feature-actions {max-width:120px;}
#drawerOpen {min-height:44px;min-width:44px;}
}
#researchMode {max-width:130px;padding:7px 8px;border:1px solid var(--line);border-radius:6px;background:var(--canvas);color:var(--ink);font:inherit;font-size:13px;}
#researchModeLabel {font-size:12px;color:var(--ink-muted);}