feat: add durable Hope primary web chat shell

This commit is contained in:
snowspeeder committed 2026-10-03 08:44:44 -04:00
1 parent 985c812a75
commit 2261208a1b
4 files changed
+396 -5

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
"""Offline Chromium tests of real Hope assets; no NAS or live provider."""
from pathlib import Path
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
def fixture(page, replay=False):
calls, streams = [], []
conversation = dict(id='c1', title='A saved thought', workspace_id=None, pinned=False, revision=1, archived=False)
original = dict(id='m1', role='user', content='Original question', status='completed')
conversations, messages = {'c1': conversation}, {'c1': [original]}
preferences = dict(palette='opal', appearance='light', revision=1)
def route(r):
path, method = urlparse(r.request.url).path, r.request.method
body = r.request.post_data_json if r.request.post_data else {}
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/capabilities': data = {'version':1,'features':{}}
elif path == '/api/preferences':
if method == 'PUT': preferences.update(body); preferences['revision'] += 1
data = {'preferences': preferences}
elif path == '/api/workspaces': data = {'workspaces': []}
elif path == '/api/conversations':
if method == 'POST':
conversations['c2'] = dict(conversation, id='c2', title=body['title']); messages['c2'] = []
data = {'conversation': conversations['c2']}
else: data = {'conversations': list(conversations.values()), 'next_cursor': None}
elif path.endswith('/events'):
streams.append(r.request.url)
message = 'id: 1\nevent: message\ndata: {"message":{"id":"a1","role":"assistant","content":"## Answer\\n**Safe** and `clear`.\\n<script>window.injected=true</script>\\n[bad](javascript:alert(1))","status":"completed"}}\n\n'
done = 'id: 2\nevent: done\ndata: {"run":{"id":"r1","status":"completed"}}\n\n'
r.fulfill(body=message if replay and len(streams) == 1 else message + done, content_type='text/event-stream'); return
elif path.endswith('/messages'):
cid = path.split('/')[3]; message = dict(id='u2', role='user', content=body['content'], status='completed'); messages[cid].append(message)
data = {'message':message,'run':{'id':'r1','status':'running'}}
elif path.endswith('/branch') and method == 'POST':
conversations['branch'] = dict(conversation, id='branch', title='New branch'); messages['branch'] = []
data = {'conversation':conversations['branch']}
elif path.startswith('/api/conversations/'):
cid = path.split('/')[3]
if method == 'DELETE': del conversations[cid]; data = {'ok':True}
elif method == 'PATCH': conversations[cid].update(body); conversations[cid]['revision'] += 1; data = {'conversation':conversations[cid]}
else: data = {'conversation':conversations[cid],'messages':messages[cid],'runs':[],'next_cursor':None}
elif path == '/api/auth/logout': data = {'ok':True}
else:
asset = ROOT / 'web' / (path.lstrip('/') or 'index.html')
if asset.is_file():
r.fulfill(body=asset.read_bytes(), content_type={'.html':'text/html','.css':'text/css','.js':'application/javascript','.svg':'image/svg+xml'}.get(asset.suffix,'application/octet-stream')); return
r.fulfill(status=404,json={'error':'Unavailable'}); return
r.fulfill(json=data)
page.route('https://hope.test/**', route)
return calls, streams, conversations, messages
def test_chat_crud_replay_safe_markdown_and_preserved_branches():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':1280,'height':900})
errors=[]; page.on('pageerror',lambda error: errors.append(str(error)))
calls, streams, conversations, messages = fixture(page, replay=True)
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('#chatTimeline')).to_contain_text('Original question')
page.locator('#pinConversation').click(); expect(page.locator('#pinConversation')).to_have_text('Unpin')
page.locator('#primaryInput').fill('A new question'); page.locator('#primarySend').click()
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
expect(page.locator('#primaryInput')).to_be_enabled(timeout=10000)
assert len(streams) == 2 and streams[-1].endswith('after=1')
assert page.locator('[data-message-id="a1"]').count() == 1
assert page.locator('#chatTimeline script').count() == 0
assert page.locator('#chatTimeline a[href^="javascript:"]').count() == 0
assert page.evaluate('window.injected') is None
assert not any(path == '/token' for _,path,_ in calls)
page.get_by_role('button',name='Edit in new branch').first.click()
page.locator('#chatActionValue').fill('Revised question'); page.locator('#chatActionSave').click()
expect(page.locator('#chatTitle')).to_have_text('New branch')
expect(page.locator('#chatTimeline')).to_contain_text('Revised question')
assert messages['c1'][0]['content'] == 'Original question'
page.locator('#openPreferences').click(); 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.locator('#closePreferences').click(); page.screenshot(path='/tmp/hope-chat-desktop.png')
page.locator('#deleteConversation').click(); page.locator('#chatActionSave').click()
expect(page.locator('#chatTitle')).to_have_text('New chat'); assert 'branch' not in conversations
assert errors == []
browser.close()
def test_mobile_drawer_keyboard_voice_and_logout():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':390,'height':844}); fixture(page)
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.keyboard.press('Escape'); expect(page.locator('#drawerOpen')).to_be_focused()
page.locator('#drawerOpen').click(); page.locator('#openVoice').click()
expect(page.locator('#startBtn')).to_be_visible(); expect(page.locator('#settingsBtn')).to_be_visible()
page.locator('#backToChat').click(); expect(page.locator('#primaryChat')).to_be_visible()
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.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()
assert 'Original question' not in page.locator('#chatTimeline').inner_text()
browser.close()
+178
View File
@@ -0,0 +1,178 @@
// Durable HTTP chat. Deliberately independent of the LiveKit composer.
(() => {
'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, pending: null};
let searchTimer, listVersion = 0, action;
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;
$('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim();
$('cancelRun').hidden = terminal(state.run);
for (const id of ['pinConversation', 'renameConversation', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy;
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
$('chatTitle').textContent = state.conversation?.title || 'New chat';
}
function selected(conversation) {
state.conversation = conversation;
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: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; }
(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() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').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(); };
// Markdown uses DOM text nodes only: HTML is always literal; unsafe URLs never become links.
function inline(parent, text) {
const expression = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^\s)]+\))/g;
let last = 0;
for (const match of text.matchAll(expression)) {
parent.append(document.createTextNode(text.slice(last, match.index))); const token = match[0]; let node;
if (token.startsWith('`')) {node = document.createElement('code'); node.textContent = token.slice(1, -1);}
else if (token.startsWith('**')) {node = document.createElement('strong'); node.textContent = token.slice(2, -2);}
else if (token.startsWith('*')) {node = document.createElement('em'); node.textContent = token.slice(1, -1);}
else {const parts = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); let url; try {url = new URL(parts[2]);} catch {} if (url && ['https:', 'http:', 'mailto:'].includes(url.protocol)) {node = document.createElement('a'); node.href = url.href; node.rel = 'noopener noreferrer'; node.textContent = parts[1];} else node = document.createTextNode(token);}
parent.append(node); last = match.index + token.length;
}
parent.append(document.createTextNode(text.slice(last)));
}
function markdown(text) {
const root = document.createElement('div'); root.className = 'markdown'; let code = null, list = null;
for (const line of String(text || '').split('\n')) {
if (line.startsWith('```')) { if (code) {code = null;} else {const pre = document.createElement('pre'); code = document.createElement('code'); pre.append(code); root.append(pre);} list = null; continue; }
if (code) {code.append(document.createTextNode(line + '\n')); continue;}
const item = /^(?:[-*] |\d+\. )(.*)$/.exec(line);
if (item) {const type = /^\d/.test(line) ? 'ol' : 'ul'; if (!list || list.tagName.toLowerCase() !== type) {list = document.createElement(type); root.append(list);} const li = document.createElement('li'); inline(li, item[1]); list.append(li); continue;}
list = null; if (!line.trim()) continue;
const heading = /^(#{1,6}) (.*)$/.exec(line); const node = document.createElement(heading ? 'h' + Math.min(heading[1].length + 1, 6) : line.startsWith('> ') ? 'blockquote' : 'p'); inline(node, heading ? heading[2] : line.replace(/^> /, '')); root.append(node);
}
return root;
}
function render() {
const timeline = $('chatTimeline'), atBottom = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 100, scroll = timeline.scrollTop;
timeline.replaceChildren();
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); }
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';
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)));
else {actions.append(button('Branch from here', () => branch(message))); const index = state.messages.indexOf(message); const prior = state.messages.slice(0, index).reverse().find(m => m.role === 'user'); if (prior) actions.append(button('Regenerate', () => branch(prior, prior.content, true)));}
}
if (['failed', 'cancelled'].includes(message.status)) {const status = document.createElement('span'); status.textContent = message.status === 'failed' ? 'Response failed' : 'Response stopped'; actions.append(status);}
row.append(actions); timeline.append(row);
}
timeline.scrollTop = atBottom ? timeline.scrollHeight : scroll; controls();
}
async function list(append = false) {
const version = ++listVersion, epoch = state.epoch;
const params = new URLSearchParams({q: $('conversationSearch').value, archived: 'false', limit: '50'});
if (state.workspace) params.set('workspace_id', state.workspace); if (append && state.cursor) params.set('cursor', state.cursor);
const data = await api('/api/conversations?' + params); if (version !== listVersion || epoch !== state.epoch) return;
if (!append) { $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); }
for (const conversation of data.conversations) {const b = button(conversation.title || 'Untitled chat', () => open(conversation.id), 'conversation-link'); b.setAttribute('aria-current', conversation.id === state.conversation?.id ? 'page' : 'false'); $(conversation.pinned ? 'pinnedConversations' : 'recentConversations').append(b);}
state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor;
}
async function open(id) {
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;
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;}
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();};
$('conversationSearch').oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => list().catch(e => notice(e.message)), 200); };
$('loadMoreConversations').onclick = () => list(true).catch(e => notice(e.message));
async function update(patch) {const epoch = state.epoch; const data = await api('/api/conversations/' + enc(state.conversation.id), 'PATCH', {revision: state.conversation.revision, ...patch}); if (epoch !== state.epoch) return; selected(data.conversation); await list();}
$('pinConversation').onclick = () => update({pinned: !state.conversation.pinned}).catch(e => notice(e.message));
function dialog(title, value, fn, description = '', needsValue = true) {
action = fn; $('chatActionTitle').textContent = title; $('chatActionDescription').textContent = description; $('chatActionValue').value = value; $('chatActionValue').hidden = !needsValue; $('chatActionLabel').hidden = !needsValue; $('chatActionValue').required = needsValue; $('chatActionError').textContent = ''; $('chatActionDialog').showModal();
}
$('chatActionCancel').onclick = () => $('chatActionDialog').close();
$('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('Archive conversation?', '', async () => {await update({archived: true}); reset(); await list();}, 'The conversation is retained, but leaves Recent.', 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();} }
function edit(message) {dialog('Edit in a new branch', message.content, content => branch(message, content, true), 'Your original conversation stays unchanged.');}
function upsert(message) {const index = state.messages.findIndex(m => m.id === message.id); if (index < 0) state.messages.push(message); else state.messages[index] = message;}
async function submit() {
const content = $('primaryInput').value.trim(); if (!content || window.HopeFeatures?.hasPendingUploads() || state.voiceActive || state.busy || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return;
state.busy = true; controls(); const epoch = state.epoch;
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 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();}
}
$('primaryComposer').onsubmit = event => {event.preventDefault(); submit();}; $('primaryInput').oninput = controls;
$('primaryInput').onkeydown = event => {if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {event.preventDefault(); submit();}};
async function stream(run) {
stopStream(); const controller = new AbortController(); state.stream = controller; const epoch = state.epoch; let after = 0, retries = 0;
try {
while (!controller.signal.aborted && epoch === state.epoch) {
try {
const response = await fetch('/api/runs/' + enc(run.id) + '/events?after=' + after, {credentials: 'same-origin', signal: controller.signal, headers: {Accept: 'text/event-stream'}});
if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Response stream unavailable (' + response.status + ').');
const reader = response.body.getReader(), decoder = new TextDecoder(); let buffer = '', done = false;
while (!done) {const part = await reader.read(); buffer += decoder.decode(part.value || new Uint8Array(), {stream: !part.done}).replace(/\r\n/g, '\n'); let boundary;
while ((boundary = buffer.indexOf('\n\n')) >= 0) {const block = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); let type = 'message', id = 0, raw = [];
for (const line of block.split('\n')) {if (line.startsWith('event:')) type = line.slice(6).trim(); else if (line.startsWith('id:')) id = Number(line.slice(3)); else if (line.startsWith('data:')) raw.push(line.slice(5).trimStart());}
if (!raw.length || (id && id <= after)) continue; const data = JSON.parse(raw.join('\n')); if (controller.signal.aborted || epoch !== state.epoch) return; if (id) after = id;
if (type === 'message') {upsert(data.message || data); render();}
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 (part.done) done = true;
}
if (terminal(state.run)) break;
retries = 0;
} catch (error) {if (controller.signal.aborted) return; if (++retries >= 3) {notice('Connection interrupted. Reopen this conversation to resume the response.'); break;}}
await new Promise(resolve => {const timer = setTimeout(resolve, 1000); controller.signal.addEventListener('abort', () => {clearTimeout(timer); resolve();}, {once: true});});
}
} finally {if (state.stream === controller) {state.stream = null; $('runProgress').replaceChildren(); if (state.run?.error) notice(state.run.error); controls(); render();}}
}
function approval(data, runId) {const area = $('runProgress'); area.replaceChildren(); const text = document.createElement('p'); text.textContent = data.prompt || 'Hope needs your permission to continue.'; area.append(text); for (const decision of ['approve', 'deny']) area.append(button(decision === 'approve' ? 'Approve' : 'Deny', async () => {const result = await api('/api/runs/' + enc(runId) + '/approval', 'POST', {approval_id: data.approval_id, decision}); state.run = result.run; area.replaceChildren(); controls();}));}
$('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); }
}
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.');}
$('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;}
$('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);} };
$('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;} };
HopeAuth.onChange((user, changed) => {
if (changed || !user) {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;
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);});
});
})();
+9 -5
View File
@@ -4,16 +4,17 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#1C191F"> <meta name="theme-color" content="#1C191F">
<title>Hope · Voice companion</title> <title>Hope · Your private companion</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="favicon.svg"> <link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="apple-touch-icon" href="brand/apple-touch-icon.png"> <link rel="apple-touch-icon" href="brand/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="brand/hope-icon-192.png"> <link rel="icon" type="image/png" sizes="192x192" href="brand/hope-icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="brand/hope-icon-512.png"> <link rel="icon" type="image/png" sizes="512x512" href="brand/hope-icon-512.png">
<link rel="stylesheet" href="hope-themes.css">
<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="#conversation">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>
@@ -27,7 +28,8 @@
<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> <main class="app-shell" id="authenticatedApp" hidden>
<header class="topbar"> <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><h2>Recent</h2><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><div 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="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">
<div class="brand"> <div class="brand">
<span class="brand-glyph" aria-hidden="true"><i></i></span> <span class="brand-glyph" aria-hidden="true"><i></i></span>
<div> <div>
@@ -138,7 +140,7 @@
</form> </form>
</section> </section>
</div> </div>
</main> </section> </main>
<div class="sheet-backdrop" id="sheetBackdrop" hidden></div> <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="settings-sheet" id="settingsSheet" hidden inert role="dialog" aria-modal="true" aria-label="Hope settings">
@@ -205,7 +207,9 @@
<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>
<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><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>
<script src="app.js"></script> <script src="app.js"></script>
<script src="chat.js"></script>
<script src="features.js"></script>
</body> </body>
</html> </html>
+99
View File
@@ -1424,3 +1424,102 @@ body[data-mode="text"] .mic-meter {
.admin-header { flex-wrap: wrap; } .admin-header { flex-wrap: wrap; }
.login-gate { padding: 1.5rem; margin-top: 7vh; } .login-gate { padding: 1.5rem; margin-top: 7vh; }
} }
/* Primary Hope shell: quiet Opal, system-native typography, a single reading column. */
:root { --accent: #8c3d63; --accent-ink: #fff; }
body {padding:0;background:var(--canvas);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;}
.app-shell {display:grid;grid-template-columns:264px minmax(0,1fr);height:100dvh;width:100%;max-width:none;margin:0;gap:0;}
.chat-sidebar {background:var(--surface);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:24px 16px 16px;overflow:auto;min-width:0;}
.sidebar-brand {display:flex;align-items:center;gap:12px;margin-bottom:24px;padding:0 10px;font-size:23px;letter-spacing:-.025em;}
.hope-mark {font-size:32px;color:var(--accent);line-height:1;}
.new-chat {justify-content:flex-start;background:var(--surface-raised);width:100%;margin-bottom:16px;}
.chat-sidebar input {width:100%;padding:11px 12px;background:var(--canvas);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-size:14px;}
.chat-sidebar nav {margin-top:22px;flex:1;}
.chat-sidebar h2 {font-size:12px;font-weight:600;color:var(--ink-muted);margin:22px 10px 8px;letter-spacing:0;text-transform:none;}
.conversation-link {border:0;background:transparent;color:var(--ink);display:block;text-align:left;border-radius:8px;width:100%;padding:10px;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-height:40px;cursor:pointer;}
.conversation-link:hover,.conversation-link[aria-current="page"] {background:var(--surface-raised);}
.nav-heading {display:flex;align-items:center;justify-content:space-between;}
.workspace-row {display:flex;align-items:center;flex-wrap:wrap;}
.workspace-row .conversation-link {width:100%;}
.workspace-row .btn {font-size:12px;min-height:30px;padding:4px 10px;color:var(--ink-muted);}
.sidebar-footer {display:flex;flex-direction:column;gap:4px;margin-top:20px;border-top:1px solid var(--line);padding-top:14px;}
.sidebar-footer .btn {justify-content:flex-start;}
.primary-chat {display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--canvas);}
.chat-topbar {display:flex;align-items:center;gap:16px;padding:18px 28px;border-bottom:1px solid var(--line);min-height:76px;flex-wrap:wrap;}
.chat-topbar h1 {font-size:16px;line-height:1.4;letter-spacing:-.015em;flex:1;min-width:100px;overflow-wrap:anywhere;}
.conversation-actions {display:flex;gap:2px;flex-wrap:wrap;}
.conversation-actions .btn {font-size:12px;padding:8px;min-height:36px;}
.chat-notice {font-size:14px;color:var(--ink-muted);margin:0 28px;}
.chat-notice:not(:empty) {padding:12px 0;}
.chat-timeline {flex:1;min-height:0;overflow:auto;padding:30px max(24px,calc((100% - 740px)/2));scrollbar-gutter:stable;}
.chat-welcome {margin:clamp(50px,15vh,180px) auto 32px;text-align:center;max-width:440px;}
.chat-welcome h2 {font-size:28px;font-weight:500;letter-spacing:-.025em;line-height:1.2;margin:20px 0 12px;text-wrap:balance;}
.chat-welcome p {color:var(--ink-muted);font-size:15px;line-height:1.7;}
.chat-message {margin:0 0 32px;max-width:74ch;font-size:16px;line-height:1.75;overflow-wrap:anywhere;}
.chat-message h2 {font-size:13px;font-weight:600;margin-bottom:10px;color:var(--ink-muted);}
.chat-message.user .markdown {background:var(--surface);border-radius:12px;padding:14px 18px;}
.markdown p {margin:0 0 10px;text-wrap:pretty;}
.markdown p:last-child {margin-bottom:0;}
.markdown h2,.markdown h3,.markdown h4 {font-size:19px;margin:20px 0 8px;color:var(--ink);}
.markdown ul,.markdown ol {padding-left:24px;margin:12px 0;}
.markdown a {color:var(--accent);text-decoration:underline;}
.markdown pre {overflow:auto;max-width:100%;padding:16px;background:var(--surface);border-radius:8px;font-size:13px;line-height:1.6;margin:14px 0;}
.markdown code {font-family:ui-monospace,SFMono-Regular,Consolas,monospace;background:var(--surface-raised);padding:2px 4px;border-radius:3px;}
.markdown pre code {padding:0;background:none;}
.markdown blockquote {border:1px solid var(--line);padding:12px 16px;margin:12px 0;border-radius:8px;}
.message-actions {display:flex;flex-wrap:wrap;gap:2px;margin-top:8px;align-items:center;font-size:12px;color:var(--ink-muted);}
.message-actions .btn {font-size:12px;min-height:32px;padding:4px 8px;}
.primary-composer {width:calc(100% - 48px);max-width:740px;margin:12px auto 0;border:1px solid var(--line);border-radius:14px;padding:16px;background:var(--surface);}
.primary-composer textarea {display:block;resize:vertical;min-height:62px;max-height:220px;width:100%;border:0;background:none;color:var(--ink);font:inherit;line-height:1.6;outline-offset:4px;}
textarea::placeholder,input::placeholder {color:var(--ink-muted);opacity:1;}
.composer-footer {display:flex;align-items:center;gap:8px;justify-content:flex-end;margin-top:12px;}
.composer-footer span {font-size:11px;color:var(--ink-muted);margin-right:auto;}
.chat-footnote {font-size:11px;color:var(--ink-muted);text-align:center;padding:12px 20px 18px;}
.run-progress {max-width:740px;width:calc(100% - 48px);margin:0 auto;font-size:14px;color:var(--ink-muted);}
.btn {border-radius:8px;min-height:40px;padding:9px 14px;box-shadow:none;font-weight:500;cursor:pointer;}
.btn-primary {background:var(--accent);color:var(--accent-ink);border:0;}
.btn-primary:hover:not(:disabled) {filter:brightness(1.08);transform:none;}
.btn-secondary,.btn-ghost,.btn-icon {background:transparent;color:var(--ink);border:0;}
.btn:hover:not(:disabled) {background:var(--surface-raised);transform:none;}
.btn-primary:hover:not(:disabled) {background:var(--accent);}
button:disabled {opacity:.45;cursor:not-allowed;}
:focus-visible {outline:2px solid var(--focus,var(--accent));outline-offset:3px;}
#drawerClose,#drawerOpen {display:none;}
.legacy-voice {overflow:auto;padding:28px;min-width:0;}
.legacy-voice .workspace {grid-template-columns:1fr;}
.legacy-voice .topbar {margin-top:24px;}
.login-gate {width:calc(100% - 40px);max-width:420px;margin:12vh auto;padding:28px;background:var(--surface);border:1px solid var(--line);border-radius:12px;}
.login-gate h1 {font-size:26px;}
.login-gate p {margin:14px 0;}
dialog {max-width:520px;width:calc(100% - 32px);border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--ink);padding:24px;}
dialog::backdrop {background:rgb(0 0 0 / .35);}
dialog h2 {font-size:22px;margin-bottom:16px;}
.account-form input,.account-form select,.account-form textarea {font:inherit;color:var(--ink);background:var(--canvas);border:1px solid var(--line);border-radius:6px;padding:10px;width:100%;}
.settings-sheet,.tasks-panel {background:var(--surface);color:var(--ink);}
.settings-sheet {overflow:auto;max-height:90dvh;}
.led-wall {display:grid;grid-template-columns:repeat(21,1fr);width:240px;height:240px;gap:3px;}
.led-dot {border-radius:50%;background:var(--accent);border:0;}
@media(max-width:760px) {
.app-shell {grid-template-columns:minmax(0,1fr);}
.chat-sidebar {position:fixed;inset:0 auto 0 0;width:min(310px,85vw);z-index:60;transform:translateX(-100%);transition:transform 180ms ease-out;}
.chat-sidebar.drawer-open {transform:translateX(0);}
#drawerClose,#drawerOpen {display:inline-flex;}
#drawerClose {margin-left:auto;}
.drawer-backdrop {position:fixed;inset:0;z-index:50;border:0;background:rgb(0 0 0 / .35);}
.chat-topbar {padding:10px 12px;gap:8px;min-height:64px;}
.conversation-actions {width:100%;justify-content:flex-end;}
.chat-timeline {padding:24px 18px;}
.primary-composer {width:calc(100% - 24px);padding:12px;}
.composer-footer span {display:none;}
.chat-welcome h2 {font-size:25px;}
.chat-footnote {padding-bottom:calc(12px + env(safe-area-inset-bottom));}
.legacy-voice {padding:16px;}
}
@media(prefers-reduced-motion:reduce) {*,*::before,*::after {animation:none!important;transition:none!important;scroll-behavior:auto!important;}}
:root {--canvas:var(--hope-ground,#fcf9fb);--surface:var(--hope-surface,#f4eef2);--surface-raised:var(--hope-persona-surface,#eee2e9);--ink:var(--hope-ink,#332831);--ink-muted:var(--hope-quiet,#695662);--ink-soft:var(--hope-quiet,#695662);--line:var(--hope-control-border,#d4c3cf);--accent:var(--hope-accent,#8c3d63);--accent-ink:var(--hope-on-accent,#fff);--focus:var(--hope-focus,#8c3d63);--iris:var(--hope-accent,#8c3d63);--danger:var(--hope-alert,#a32438);color-scheme:light dark;}
.sidebar-footer .btn,.new-chat {text-align:left;display:flex;justify-content:flex-start;}
.primary-composer .btn-primary {min-width:84px;flex:0 0 auto;}
.primary-composer .btn-primary:disabled,.account-form .btn-primary:disabled,.login-gate .btn-primary:disabled {display:inline-flex;}