feat: clarify web library memory and conversation management
Rebuild & Deploy / rebuild (push) Failing after 17s
Rebuild & Deploy / rebuild (push) Failing after 17s
This commit is contained in:
1 parent
3183ebaea7
commit
41ca590744
11 files changed
+767
-70
No files matched your search
@@ -1,5 +1,15 @@
|
|||||||
# Multi-user Hermes migration — qualification and operations
|
# Multi-user Hermes migration — qualification and operations
|
||||||
|
|
||||||
|
## Web UX refinement — 2026-10-04 (offline, not deployed)
|
||||||
|
|
||||||
|
Web-only sidebar actions, Library upload/reuse and Saved drafts, scheduled-task
|
||||||
|
layout, memory revision recovery, and contextual Security/status/signout improved.
|
||||||
|
Final 43 offline browser tests passed; 85 final full-interface/internal-scroll
|
||||||
|
screenshots captured at 1440/390/320 widths with no JS or unhandled/external
|
||||||
|
requests. Independent source review findings fixed and final review approved.
|
||||||
|
See [detailed scope and evidence](docs/WEB_UX_REFINEMENT.md). No live API,
|
||||||
|
production deployment, native parity or physical audio claim for this refinement.
|
||||||
|
|
||||||
## iPhone certificate onboarding follow-up — 2026-10-01
|
## iPhone certificate onboarding follow-up — 2026-10-01
|
||||||
|
|
||||||
User reported certificate error at iOS sign-in. Current pinned native HTTPS probe
|
User reported certificate error at iOS sign-in. Current pinned native HTTPS probe
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Web UX refinement — 2026-10-04
|
||||||
|
|
||||||
|
Scope: web only. Native iOS remains unchanged until later parity work.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
- Conversation history has per-row, accessible, viewport-clamped native popover actions for pin/unpin, rename and archive/unarchive without opening the conversation. Revision/account/selection guards preserve active runs and navigation drafts during delayed responses. Deletion remains the opened conversation's top-right menu.
|
||||||
|
- Library explains reference files versus Saved drafts. Files open on entry, with prominent upload and reuse actions. Choosing files closes Library and exposes composer progress, retry/removal and errors. A saved file is revalidated before queuing; nothing sends until explicit message submission. Long filenames appear once with compact, named actions. Unsupported capabilities are explained rather than simulated.
|
||||||
|
- Scheduled task and memory forms use reusable styles outside the original Library mount. Labels, controls, hints, actions and checkbox alignment remain stacked and readable. Activity closes mobile navigation before opening and keeps a reachable close header during scroll. History scrolls independently so account/Library/Activity remain reachable.
|
||||||
|
- Memory & data explains account context versus chat history/files, gives examples, and exposes revision-aware save/clear. Compare-latest preserves local edits and requires explicitly accepting a reviewed current revision before separately saving; no automatic conflict overwrite.
|
||||||
|
- Security shows the actual connected host, HTTPS transport status, current account and session scope, with a real sign-out action. It explains administrator-managed resets and does not fabricate session/device lists, self-service reset, or two-factor controls.
|
||||||
|
- Successful model changes requalify file/image support; pending/failed refreshes block images, latest-response generation wins, and Retry checks support again.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Full real-asset screenshot review used offline mocked same-origin API fixtures, not NAS/account data. 71 baseline captures from frozen committed assets; final 85 screenshots at desktop 1440×1000, mobile 390×844 and 320×740, including internal dialog/panel scrolling. Covers conversation/sidebar, Files/Saved drafts and editor, tasks list/create/edit, Memory & data, Security, Account and Administration.
|
||||||
|
|
||||||
|
Artifacts: `/tmp/hope-web-ux-before/`, `/tmp/hope-web-ux-qualified/`; reusable harness `/tmp/hope_web_screenshots.py`. Final capture receipt reports zero browser errors, unknown requests or external requests at all widths. Screenshots were inspected, including task collision baseline and final narrow forms, Library filename/actions, memory editor/navigation and mobile history/footer.
|
||||||
|
|
||||||
|
Final integrated offline browser selection: **43 passed** in 70.91s:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv-tests/bin/python -m pytest --noconftest -p no:cacheprovider \
|
||||||
|
tests/unit/test_settings_web.py tests/unit/test_chat_features_web.py \
|
||||||
|
tests/unit/test_sidebar_actions_web.py tests/unit/test_chat_web.py \
|
||||||
|
tests/unit/test_voice_settings_ui.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
JavaScript syntax and diff whitespace checks passed. Independent read-only source/contract review initially found asynchronous ownership, hidden feedback, capability refresh and revision recovery issues; these were corrected with regressions and final review approved the scope. Earlier retry test failed because it asserted before the awaited retry finished; corrected to wait for completed state, not to remove behavior coverage.
|
||||||
|
|
||||||
|
No production deployment, live API request, speech/device qualification or iOS parity implementation is claimed. Existing untracked design artifacts were left untouched.
|
||||||
@@ -5,7 +5,7 @@ import unittest
|
|||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
MOCK = r'''() => {
|
MOCK = r'''() => {
|
||||||
window.calls = []; window.listeners = []; window.pendingUpload = false; window.conflict = false;
|
window.calls = []; window.listeners = []; window.pendingUpload = false; window.conflict = false; window.uploadError = ''; window.extraction = 'ready'; window.file = {id:'c'.repeat(32),filename:'notes.txt',mime:'text/plain',size:5,extraction_status:'ready'};
|
||||||
window.capabilities = {attachments:true, artifacts:true, scheduled_tasks:true, memory_controls:true, image_input:true,
|
window.capabilities = {attachments:true, artifacts:true, scheduled_tasks:true, memory_controls:true, image_input:true,
|
||||||
attachment_mimes:['text/plain','text/markdown','application/pdf','image/png'], upload_bytes:8388608};
|
attachment_mimes:['text/plain','text/markdown','application/pdf','image/png'], upload_bytes:8388608};
|
||||||
window.artifact = {id:'a'.repeat(32),title:'Draft <img src=x onerror=alert(1)>',kind:'markdown',revision:2,content:'<script>window.injected=true</script>'};
|
window.artifact = {id:'a'.repeat(32),title:'Draft <img src=x onerror=alert(1)>',kind:'markdown',revision:2,content:'<script>window.injected=true</script>'};
|
||||||
@@ -16,11 +16,12 @@ MOCK = r'''() => {
|
|||||||
binary:options.body instanceof File,headers:options.headers});
|
binary:options.body instanceof File,headers:options.headers});
|
||||||
if (path==='/api/capabilities') return {features:capabilities};
|
if (path==='/api/capabilities') return {features:capabilities};
|
||||||
if (path==='/api/attachments') {
|
if (path==='/api/attachments') {
|
||||||
if(options.method==='GET') return {attachments:[{id:'c'.repeat(32),filename:'notes.txt',size:5,extraction_status:'ready'}]};
|
if(options.method==='GET') return {attachments:[file]};
|
||||||
if(pendingUpload) await new Promise((resolve,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('Stopped','AbortError'))));
|
if(pendingUpload) await new Promise((resolve,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('Stopped','AbortError'))));
|
||||||
return {attachment:{id:'c'.repeat(32),filename:options.body.name,extraction_status:'ready'}};
|
if(uploadError) throw new Error(uploadError); return {attachment:{id:'c'.repeat(32),filename:options.body.name,mime:options.body.type,size:options.body.size,extraction_status:extraction}};
|
||||||
}
|
}
|
||||||
if(path==='/api/artifacts') {const {content,...metadata}=artifact;return options.method==='POST'?{artifact:{...artifact,...JSON.parse(options.body),revision:1}}:{artifacts:[metadata]};}
|
if(path.startsWith('/api/attachments/')) return {attachment:file};
|
||||||
|
if(path==='/api/artifacts') {const {content,...metadata}=artifact;return options.method==='POST'?{artifact:{...artifact,...JSON.parse(options.body),revision:1}}:{artifacts:[metadata]};}
|
||||||
if(path.startsWith('/api/artifacts/')) {
|
if(path.startsWith('/api/artifacts/')) {
|
||||||
if(options.method==='PATCH') {if(conflict) throw new Error('Artifact revision conflict');artifact={...artifact,...JSON.parse(options.body),revision:artifact.revision+1};}
|
if(options.method==='PATCH') {if(conflict) throw new Error('Artifact revision conflict');artifact={...artifact,...JSON.parse(options.body),revision:artifact.revision+1};}
|
||||||
return {artifact};
|
return {artifact};
|
||||||
@@ -55,17 +56,165 @@ class ChatFeaturesWebTests(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.page = self.browser.new_page()
|
self.page = self.browser.new_page()
|
||||||
self.page.set_content('<html><body><aside id="chatSidebar"></aside><form id="primaryComposer"><div id="featureActions"></div><div id="attachmentPreviews"></div></form></body></html>')
|
self.page.set_content('<html><body><aside id="chatSidebar"></aside><dialog id="libraryDialog"><div id="libraryContent"></div></dialog><form id="primaryComposer"><textarea id="primaryInput"></textarea><button id="sendMock" type="submit">Send</button><div id="featureActions"></div><div id="attachmentPreviews"></div></form></body></html>')
|
||||||
self.page.evaluate(MOCK)
|
self.page.evaluate(MOCK)
|
||||||
self.page.add_script_tag(path=str(ROOT / 'web/features.js'))
|
self.page.add_script_tag(path=str(ROOT / 'web/features.js'))
|
||||||
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
|
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
|
||||||
|
self.page.evaluate('document.getElementById("primaryComposer").addEventListener("submit",e=>{e.preventDefault();calls.push({path:"/api/conversations/mock/messages",method:"POST",body:{attachment_ids:HopeFeatures.attachmentIDs()}});HopeFeatures.clearAttachmentsAfterSend();})')
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.page.close()
|
self.page.close()
|
||||||
|
|
||||||
def open_panel(self, name):
|
def open_panel(self, name):
|
||||||
|
self.page.evaluate('document.getElementById("libraryDialog").showModal()')
|
||||||
self.page.get_by_text(name, exact=True).click()
|
self.page.get_by_text(name, exact=True).click()
|
||||||
|
|
||||||
|
def test_library_upload_queues_without_send(self):
|
||||||
|
self.open_panel('Files')
|
||||||
|
with self.page.expect_file_chooser() as chooser:
|
||||||
|
self.page.get_by_role('button', name='Upload files', exact=True).click()
|
||||||
|
chooser.value.set_files({'name':'library.txt','mimeType':'text/plain','buffer':b'Library'})
|
||||||
|
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||||
|
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path.includes("/messages")).length'), 0)
|
||||||
|
self.assertIn('nothing is sent until you send', self.page.locator('#hopeFeatures').text_content())
|
||||||
|
self.assertFalse(self.page.evaluate('document.getElementById("libraryDialog").open'))
|
||||||
|
self.assertEqual(self.page.evaluate('document.activeElement.id'), 'primaryInput')
|
||||||
|
self.assertTrue(self.page.locator('#featureComposerStatus').is_visible())
|
||||||
|
self.assertIn('library.txt', self.page.locator('#featureComposerStatus').inner_text())
|
||||||
|
|
||||||
|
def test_rejected_files_report_in_current_surface(self):
|
||||||
|
self.open_panel('Files')
|
||||||
|
with self.page.expect_file_chooser() as chooser:
|
||||||
|
self.page.get_by_role('button', name='Upload files', exact=True).click()
|
||||||
|
chooser.value.set_files({'name':'unsupported.svg','mimeType':'image/svg+xml','buffer':b'bad'})
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("Unsupported")')
|
||||||
|
self.assertTrue(self.page.evaluate('document.getElementById("libraryDialog").open'))
|
||||||
|
self.assertIn('unsupported.svg', self.page.locator('.feature-section').filter(has=self.page.get_by_text('Files', exact=True)).locator('.feature-status').inner_text())
|
||||||
|
self.page.evaluate('document.getElementById("libraryDialog").close()')
|
||||||
|
self.page.evaluate('HopeFeatures.addFiles([new File([],"empty.txt",{type:"text/plain"})])')
|
||||||
|
self.assertTrue(self.page.locator('#featureComposerStatus').is_visible())
|
||||||
|
self.assertIn('empty.txt', self.page.locator('#featureComposerStatus').inner_text())
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
|
||||||
|
def test_reuse_queues_then_explicit_send_and_focuses(self):
|
||||||
|
self.open_panel('Files')
|
||||||
|
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||||
|
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||||
|
self.assertFalse(self.page.evaluate('document.getElementById("libraryDialog").open'))
|
||||||
|
self.assertEqual(self.page.evaluate('document.activeElement.id'), 'primaryInput')
|
||||||
|
self.assertEqual(self.page.evaluate('calls.filter(c=>c.method==="POST").length'), 0)
|
||||||
|
self.assertIn('notes.txt queued', self.page.locator('#featureStatus').text_content())
|
||||||
|
self.page.get_by_role('button', name='Send', exact=True).click()
|
||||||
|
self.assertEqual(self.page.evaluate('calls.find(c=>c.path.includes("/messages")).body.attachment_ids'), ['c'*32])
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
|
||||||
|
def test_reuse_checks_blocked_images_capacity_and_stale_metadata(self):
|
||||||
|
self.page.evaluate('file.extraction_status="encrypted"')
|
||||||
|
self.open_panel('Files')
|
||||||
|
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("could not be extracted")')
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
self.page.evaluate('file.extraction_status="image";file.mime="image/png";capabilities.image_input=false;HopeFeatures.refresh()')
|
||||||
|
self.page.get_by_role('button', name='Refresh files').click()
|
||||||
|
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("Image input")')
|
||||||
|
self.page.evaluate('file.extraction_status="ready";file.mime="text/plain";capabilities.image_input=true;capabilities.attachments_per_message=1;HopeFeatures.refresh()')
|
||||||
|
self.page.get_by_role('button', name='Refresh files').click()
|
||||||
|
self.page.evaluate('HopeFeatures.addFiles([new File(["x"],"pending.txt",{type:"text/plain"})]);pendingUpload=true')
|
||||||
|
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||||
|
self.page.evaluate('file.id="d".repeat(32);document.getElementById("libraryDialog").showModal()')
|
||||||
|
self.page.get_by_role('button', name='Refresh files').click()
|
||||||
|
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("at most 1")')
|
||||||
|
self.page.evaluate('HopeFeatures.clearAttachmentsAfterSend();file.extraction_status="ready"')
|
||||||
|
self.page.get_by_role('button', name='Refresh files').click()
|
||||||
|
self.page.evaluate('file.extraction_status="failed"')
|
||||||
|
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("could not be extracted")')
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
|
||||||
|
def test_upload_error_blocked_and_reuse_account_teardown(self):
|
||||||
|
self.page.evaluate('uploadError="Account storage is full"')
|
||||||
|
self.page.locator('input[type=file]').set_input_files({'name':'full.txt','mimeType':'text/plain','buffer':b'Hello'})
|
||||||
|
self.page.get_by_role('button', name='Retry upload').wait_for()
|
||||||
|
self.assertTrue(self.page.evaluate('HopeFeatures.hasPendingUploads()'))
|
||||||
|
self.page.evaluate('uploadError="";extraction="failed"')
|
||||||
|
self.page.get_by_role('button', name='Retry upload').click()
|
||||||
|
self.page.wait_for_function('document.getElementById("attachmentPreviews").textContent.includes("blocked")')
|
||||||
|
self.assertTrue(self.page.evaluate('HopeFeatures.hasPendingUploads()'))
|
||||||
|
self.page.evaluate('changeAccount({id:"owner-b",status:"ready"})')
|
||||||
|
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
self.open_panel('Files')
|
||||||
|
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||||
|
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||||
|
self.page.evaluate('changeAccount(null)')
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
self.assertEqual(self.page.locator('.feature-editor').filter(has_text='Updated facts').count(), 0)
|
||||||
|
|
||||||
|
def test_reuse_inflight_account_change_cannot_queue_previous_account(self):
|
||||||
|
self.page.evaluate('''() => {const original=HopeAuth.request;HopeAuth.request=async(path,options)=>{if(path.startsWith('/api/attachments/')){window.reuseSignal=options.signal;await new Promise(resolve=>window.resolveReuse=resolve);}return original(path,options);};}''')
|
||||||
|
self.open_panel('Files')
|
||||||
|
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||||
|
self.page.wait_for_function('!!window.resolveReuse')
|
||||||
|
self.page.evaluate('changeAccount({id:"owner-b",status:"ready"});resolveReuse()')
|
||||||
|
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
|
||||||
|
self.assertTrue(self.page.evaluate('reuseSignal.aborted'))
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
self.assertNotIn('queued', self.page.locator('#featureStatus').text_content())
|
||||||
|
|
||||||
|
def test_overlapping_capability_refresh_ignores_old_vision_response(self):
|
||||||
|
self.page.evaluate('''() => {const original=HopeAuth.request;window.capabilityResolvers=[];HopeAuth.request=(path,options)=>path==='/api/capabilities'?new Promise(resolve=>capabilityResolvers.push(resolve)):original(path,options);window.oldRefresh=HopeFeatures.refresh();window.newRefresh=HopeFeatures.refresh();}''')
|
||||||
|
self.page.evaluate('''() => {capabilityResolvers[1]({features:{...capabilities,image_input:false}});}''')
|
||||||
|
self.page.evaluate('() => newRefresh')
|
||||||
|
self.assertNotIn('image/png', self.page.locator('input[type=file]').get_attribute('accept'))
|
||||||
|
self.page.evaluate('''() => {capabilityResolvers[0]({features:{...capabilities,image_input:true}});}''')
|
||||||
|
self.page.evaluate('() => oldRefresh')
|
||||||
|
self.assertNotIn('image/png', self.page.locator('input[type=file]').get_attribute('accept'))
|
||||||
|
self.page.evaluate('HopeFeatures.addFiles([new File(["x"],"image.png",{type:"image/png"})])')
|
||||||
|
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path==="/api/attachments").length'), 0)
|
||||||
|
self.assertIn('Image input is not available', self.page.locator('#featureComposerStatus').inner_text())
|
||||||
|
|
||||||
|
def test_delayed_reuse_discarded_on_thread_change(self):
|
||||||
|
self.page.evaluate("window.dispatchEvent(new CustomEvent('hope:conversation-selected',{detail:{conversation_id:'one'}}))")
|
||||||
|
self.page.evaluate("""() => {const original=HopeAuth.request;HopeAuth.request=async(path,options)=>{if(path.startsWith('/api/attachments/')) await new Promise(resolve=>window.resolveReuse=resolve); return original(path,options);};}""")
|
||||||
|
self.open_panel('Files')
|
||||||
|
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||||
|
self.page.wait_for_function('!!window.resolveReuse')
|
||||||
|
self.page.evaluate("window.dispatchEvent(new CustomEvent('hope:conversation-selected',{detail:{conversation_id:'two'}}));resolveReuse()")
|
||||||
|
self.page.wait_for_function('document.querySelector(".feature-row-actions button").disabled===false')
|
||||||
|
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||||
|
|
||||||
|
def test_real_memory_conflict_requires_explicit_reviewed_revision(self):
|
||||||
|
self.page.evaluate("""() => {window.latestMemory={section:'memory',content:'Saved facts',revision:'m1'};const original=HopeAuth.request;HopeAuth.request=async(path,options)=>{if(path==='/api/memory')return {sections:[{...latestMemory}]};if(path==='/api/memory/memory'){const data=JSON.parse(options.body);calls.push({path,body:data,method:'PUT'});if(data.revision!==latestMemory.revision)throw Object.assign(new Error('Memory revision conflict. Compare latest saved memory before retrying.'),{status:409});latestMemory={...latestMemory,content:data.content,revision:'m3'};return latestMemory;}return original(path,options);};}""")
|
||||||
|
self.open_panel('Memory')
|
||||||
|
content = self.page.get_by_label('Assistant memory', exact=True)
|
||||||
|
content.fill('My retained local draft')
|
||||||
|
self.page.evaluate('latestMemory={section:"memory",content:"Concurrent assistant edit",revision:"m2"}')
|
||||||
|
self.page.get_by_role('button', name='Save assistant memory', exact=True).click()
|
||||||
|
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("retained")')
|
||||||
|
self.page.get_by_role('button', name='Compare latest saved memory', exact=True).click()
|
||||||
|
self.page.get_by_text('Concurrent assistant edit', exact=True).wait_for()
|
||||||
|
self.assertEqual(content.input_value(), 'My retained local draft')
|
||||||
|
self.page.get_by_role('button', name='Save assistant memory', exact=True).click()
|
||||||
|
self.assertEqual(self.page.evaluate('latestMemory.content'), 'Concurrent assistant edit')
|
||||||
|
self.page.get_by_role('button', name='Use reviewed revision for next save', exact=True).click()
|
||||||
|
self.page.get_by_role('button', name='Save assistant memory', exact=True).click()
|
||||||
|
self.page.wait_for_function('latestMemory.content==="My retained local draft"')
|
||||||
|
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path==="/api/memory/memory").at(-1).body.revision'), 'm2')
|
||||||
|
|
||||||
|
def test_reusable_structures_and_task_hints_explicit_paused(self):
|
||||||
|
self.open_panel('Scheduled tasks')
|
||||||
|
self.page.get_by_role('button', name='New scheduled task').click()
|
||||||
|
self.assertTrue(self.page.get_by_label('Create paused', exact=True).is_checked())
|
||||||
|
self.assertTrue(self.page.get_by_label('Create paused', exact=True).is_visible())
|
||||||
|
self.assertEqual(self.page.locator('.feature-form .feature-field').count(), 5)
|
||||||
|
self.assertIn('timezone and next run are not inferred', self.page.locator('.feature-form').inner_text())
|
||||||
|
self.page.evaluate('const target=document.createElement("div");target.id="movedMemory";document.body.append(target);HopeFeatures.mountMemory(target);document.getElementById("libraryDialog").close();HopeFeatures.openSection("memory")')
|
||||||
|
self.page.get_by_label('About you', exact=True).wait_for()
|
||||||
|
self.assertEqual(self.page.locator('#movedMemory .feature-section').count(), 1)
|
||||||
|
self.assertIn('For example', self.page.locator('#movedMemory').inner_text())
|
||||||
|
|
||||||
def test_upload_raw_bytes_and_ack_only_clear(self):
|
def test_upload_raw_bytes_and_ack_only_clear(self):
|
||||||
self.page.locator('input[type=file]').set_input_files({'name':'notes.txt','mimeType':'text/plain','buffer':b'Hello'})
|
self.page.locator('input[type=file]').set_input_files({'name':'notes.txt','mimeType':'text/plain','buffer':b'Hello'})
|
||||||
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||||
@@ -82,7 +231,7 @@ class ChatFeaturesWebTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_retained_upload_management_delete_is_explicit(self):
|
def test_retained_upload_management_delete_is_explicit(self):
|
||||||
self.open_panel('Files')
|
self.open_panel('Files')
|
||||||
self.page.get_by_text('Delete notes.txt',exact=True).click()
|
self.page.locator('summary[aria-label="Delete notes.txt"]').click()
|
||||||
self.assertIn("old chat attachment references won't work", self.page.locator('#hopeFeatures').inner_text())
|
self.assertIn("old chat attachment references won't work", self.page.locator('#hopeFeatures').inner_text())
|
||||||
self.assertEqual(self.page.evaluate('calls.filter(c=>c.method==="DELETE").length'), 0)
|
self.assertEqual(self.page.evaluate('calls.filter(c=>c.method==="DELETE").length'), 0)
|
||||||
self.assertTrue(self.page.get_by_role('link',name='Download notes.txt').get_attribute('href').endswith('/content'))
|
self.assertTrue(self.page.get_by_role('link',name='Download notes.txt').get_attribute('href').endswith('/content'))
|
||||||
@@ -116,7 +265,7 @@ class ChatFeaturesWebTests(unittest.TestCase):
|
|||||||
self.assertTrue(self.page.locator('#hopeFeatures').is_hidden())
|
self.assertTrue(self.page.locator('#hopeFeatures').is_hidden())
|
||||||
|
|
||||||
def test_artifact_safe_preview_versions_conflict_and_export(self):
|
def test_artifact_safe_preview_versions_conflict_and_export(self):
|
||||||
self.open_panel('Artifacts')
|
self.open_panel('Saved drafts')
|
||||||
self.page.get_by_role('button',name='Edit Draft',exact=False).click()
|
self.page.get_by_role('button',name='Edit Draft',exact=False).click()
|
||||||
self.page.get_by_label('Content',exact=True).fill('<img src=x onerror=alert(1)> changed')
|
self.page.get_by_label('Content',exact=True).fill('<img src=x onerror=alert(1)> changed')
|
||||||
self.assertEqual(self.page.locator('pre').inner_text(), '<img src=x onerror=alert(1)> changed')
|
self.assertEqual(self.page.locator('pre').inner_text(), '<img src=x onerror=alert(1)> changed')
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ def settings_page():
|
|||||||
'users': [dict(id='bob', username='bob', display_name='Bob', role='user', status='error', container_name='hope-hermes-bob', error='Provisioning failed')],
|
'users': [dict(id='bob', username='bob', display_name='Bob', role='user', status='error', container_name='hope-hermes-bob', error='Provisioning failed')],
|
||||||
'preferences': dict(palette='opal', appearance='light', revision=1),
|
'preferences': dict(palette='opal', appearance='light', revision=1),
|
||||||
'model': dict(model='auto', thinking_level='none'),
|
'model': dict(model='auto', thinking_level='none'),
|
||||||
'memory': True, 'appearance_error': False, 'calls': [], 'errors': [],
|
'memory': True, 'appearance_error': False, 'capability_error': False, 'calls': [], 'errors': [],
|
||||||
}
|
}
|
||||||
page.on('pageerror', lambda error: state['errors'].append(str(error)))
|
page.on('pageerror', lambda error: state['errors'].append(str(error)))
|
||||||
|
|
||||||
@@ -32,7 +32,10 @@ def settings_page():
|
|||||||
request.fulfill(status=401, json={'error': 'Sign in required'}); return
|
request.fulfill(status=401, json={'error': 'Sign in required'}); return
|
||||||
data = {'user': state['user']}
|
data = {'user': state['user']}
|
||||||
elif path == '/api/auth/logout': data = {'ok': True}
|
elif path == '/api/auth/logout': data = {'ok': True}
|
||||||
elif path == '/api/capabilities': data = {'features': {'memory_controls': state['memory']}}
|
elif path == '/api/capabilities':
|
||||||
|
if state['capability_error']:
|
||||||
|
request.fulfill(status=503, json={'error': 'File support unavailable'}); return
|
||||||
|
data = {'features': {'memory_controls': state['memory']}}
|
||||||
elif path == '/api/research': data = {'available': False}
|
elif path == '/api/research': data = {'available': False}
|
||||||
elif path == '/api/preferences':
|
elif path == '/api/preferences':
|
||||||
if state['appearance_error']:
|
if state['appearance_error']:
|
||||||
@@ -185,6 +188,48 @@ def test_appearance_revision_save_failure_and_memory_real_controls(settings_page
|
|||||||
assert page.locator('[data-settings-panel="privacy"] #featureStatus').count() == 0
|
assert page.locator('[data-settings-panel="privacy"] #featureStatus').count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_activity_closes_drawer_and_task_fields_stack(settings_page):
|
||||||
|
page, state = settings_page
|
||||||
|
page.locator('#drawerOpen').click()
|
||||||
|
page.locator('#tasksBtn').click()
|
||||||
|
expect(page.locator('#chatSidebar')).not_to_have_class('chat-sidebar drawer-open')
|
||||||
|
expect(page.locator('#drawerBackdrop')).to_be_hidden()
|
||||||
|
expect(page.locator('#tasksPanel')).to_be_visible()
|
||||||
|
expect(page.locator('#closeTasksBtn')).to_be_focused()
|
||||||
|
page.locator('#closeTasksBtn').click()
|
||||||
|
expect(page.locator('#drawerOpen')).to_be_focused()
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_save_file_support_failure_has_working_retry(settings_page):
|
||||||
|
page, state = settings_page
|
||||||
|
open_section(page, 'preferences')
|
||||||
|
state['capability_error'] = True
|
||||||
|
page.locator('#thinkingLevelSelect').select_option('high')
|
||||||
|
page.locator('#saveModelSettings').click()
|
||||||
|
expect(page.locator('#modelSettingsStatus')).to_contain_text('file support could not be checked')
|
||||||
|
expect(page.locator('#retryModelSettings')).to_be_visible()
|
||||||
|
before = len([call for call in state['calls'] if call[1] == '/api/capabilities'])
|
||||||
|
state['capability_error'] = False
|
||||||
|
page.locator('#retryModelSettings').click()
|
||||||
|
expect(page.locator('#modelSettingsStatus')).to_contain_text('saved settings are loaded')
|
||||||
|
expect(page.locator('#retryModelSettings')).to_be_hidden()
|
||||||
|
page.wait_for_function("document.getElementById('featureAvailability').textContent.includes('Files are unavailable.')")
|
||||||
|
assert len([call for call in state['calls'] if call[1] == '/api/capabilities']) > before
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_explains_real_connection_and_signs_out(settings_page):
|
||||||
|
page, state = settings_page
|
||||||
|
open_section(page, 'security')
|
||||||
|
expect(page.locator('#securityServer')).to_have_text('hope.test')
|
||||||
|
expect(page.locator('#securityConnection')).to_contain_text('HTTPS')
|
||||||
|
expect(page.locator('#securityIdentity')).to_contain_text('@alice')
|
||||||
|
assert page.locator('[data-settings-panel="security"] input').count() == 0
|
||||||
|
page.get_by_role('button', name='Sign out of this browser', exact=True).click()
|
||||||
|
expect(page.locator('#settingsDialog')).not_to_be_visible()
|
||||||
|
expect(page.locator('#loginGate')).to_be_visible()
|
||||||
|
assert any(method == 'POST' and path == '/api/auth/logout' for method, path, _ in state['calls'])
|
||||||
|
|
||||||
|
|
||||||
def test_preferences_preserve_active_voice_and_model_owner(settings_page):
|
def test_preferences_preserve_active_voice_and_model_owner(settings_page):
|
||||||
page, state = settings_page
|
page, state = settings_page
|
||||||
page.evaluate("window.voiceEvents=[];window.addEventListener('hope:voice-state',e=>voiceEvents.push(e.detail));window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))")
|
page.evaluate("window.voiceEvents=[];window.addEventListener('hope:voice-state',e=>voiceEvents.push(e.detail));window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))")
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Offline sidebar mutations using real assets and mocked authenticated APIs."""
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
from test_chat_web import fixture
|
||||||
|
|
||||||
|
|
||||||
|
def setup(page):
|
||||||
|
calls, streams, conversations, messages = fixture(page)
|
||||||
|
conversations['c2'] = dict(conversations['c1'], id='c2', title='Another thought')
|
||||||
|
messages['c2'] = []
|
||||||
|
page.goto('https://hope.test/')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
if page.locator('#drawerOpen').is_visible():
|
||||||
|
page.locator('#drawerOpen').click()
|
||||||
|
page.get_by_role('button', name='A saved thought', exact=True).click()
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
|
||||||
|
page.locator('#primaryInput').fill('Keep my selected draft')
|
||||||
|
return calls, conversations
|
||||||
|
|
||||||
|
|
||||||
|
def menu(page, title='Another thought'):
|
||||||
|
page.get_by_role('button', name='Actions for ' + title, exact=True).click()
|
||||||
|
return page.locator('.conversation-row-menu')
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonselected_pin_rename_archive_unarchive_preserve_selected_chat():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={'width':1280, 'height':900})
|
||||||
|
calls, conversations = setup(page)
|
||||||
|
menu(page).get_by_role('button', name='Pin', exact=True).click()
|
||||||
|
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
|
||||||
|
menu(page).get_by_role('button', name='Unpin', exact=True).click()
|
||||||
|
expect(page.locator('#recentConversations')).to_contain_text('Another thought')
|
||||||
|
menu(page).get_by_role('button', name='Rename', exact=True).click()
|
||||||
|
page.locator('#chatActionValue').fill('Renamed thought')
|
||||||
|
page.locator('#chatActionSave').click()
|
||||||
|
expect(page.locator('#chatActionDialog')).not_to_be_visible()
|
||||||
|
menu(page, 'Renamed thought').get_by_role('button', name='Archive', exact=True).click()
|
||||||
|
page.locator('#chatActionSave').click()
|
||||||
|
expect(page.locator('#recentConversations')).not_to_contain_text('Renamed thought')
|
||||||
|
page.locator('#showArchived').click()
|
||||||
|
menu(page, 'Renamed thought').get_by_role('button', name='Unarchive', exact=True).click()
|
||||||
|
page.locator('#chatActionSave').click()
|
||||||
|
expect(page.locator('#recentConversations')).not_to_contain_text('Renamed thought')
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
|
||||||
|
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
|
||||||
|
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
|
||||||
|
patches = [body for method, path, body in calls if method == 'PATCH']
|
||||||
|
assert [body['revision'] for body in patches] == [1, 2, 3, 4, 5]
|
||||||
|
assert not any(method == 'GET' and path == '/api/conversations/c2' for method, path, body in calls)
|
||||||
|
assert conversations['c2']['archived'] is False
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonselected_conflict_refresh_retry_and_failed_refresh_errors():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
calls, conversations = setup(page)
|
||||||
|
def conflict(route):
|
||||||
|
if route.request.method == 'PATCH':
|
||||||
|
conversations['c2'].update(title='Newest title', revision=8)
|
||||||
|
route.fulfill(status=409, json={'error':'Stale revision'})
|
||||||
|
else:
|
||||||
|
route.fallback()
|
||||||
|
page.route('https://hope.test/api/conversations/c2', conflict)
|
||||||
|
menu(page).get_by_role('button', name='Rename', exact=True).click()
|
||||||
|
page.locator('#chatActionValue').fill('My title')
|
||||||
|
page.locator('#chatActionSave').click()
|
||||||
|
expect(page.locator('#chatActionError')).to_contain_text('Stale revision')
|
||||||
|
expect(page.locator('#chatActionError')).to_contain_text('Conversation refreshed')
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
|
||||||
|
page.unroute('https://hope.test/api/conversations/c2', conflict)
|
||||||
|
page.locator('#chatActionSave').click()
|
||||||
|
expect(page.locator('#recentConversations')).to_contain_text('My title')
|
||||||
|
assert next(body for method, path, body in reversed(calls) if method == 'PATCH')['revision'] == 8
|
||||||
|
def unavailable(route):
|
||||||
|
route.fulfill(status=503, json={'error':'Service unavailable'})
|
||||||
|
page.route('https://hope.test/api/conversations/c2', unavailable)
|
||||||
|
menu(page, 'My title').get_by_role('button', name='Pin', exact=True).click()
|
||||||
|
expect(page.locator('#chatNotice')).to_contain_text('Service unavailable')
|
||||||
|
expect(page.locator('#chatNotice')).to_contain_text('Could not refresh')
|
||||||
|
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_menu_keyboard_top_layer_and_selected_voice_safety():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={'width':390, 'height':844})
|
||||||
|
calls, conversations = setup(page)
|
||||||
|
page.locator('#drawerOpen').click()
|
||||||
|
trigger = page.get_by_role('button', name='Actions for Another thought', exact=True)
|
||||||
|
trigger.focus(); page.keyboard.press('Enter')
|
||||||
|
expect(page.locator('.conversation-row-menu')).to_be_visible()
|
||||||
|
expect(page.locator('.conversation-row-menu button').first).to_be_focused()
|
||||||
|
assert page.locator('.conversation-row-menu').evaluate("el => el.matches(':popover-open')")
|
||||||
|
assert page.locator('.conversation-row-menu').evaluate('el => {const r=el.getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth && r.top >= 0 && r.bottom <= innerHeight;}')
|
||||||
|
page.keyboard.press('Escape')
|
||||||
|
expect(page.locator('.conversation-row-menu')).not_to_be_visible()
|
||||||
|
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))")
|
||||||
|
active = menu(page, 'A saved thought')
|
||||||
|
for name in ['Pin', 'Rename', 'Archive']:
|
||||||
|
expect(active.get_by_role('button', name=name, exact=True)).to_be_disabled()
|
||||||
|
page.keyboard.press('Escape')
|
||||||
|
if page.locator('#drawerOpen').get_attribute('aria-expanded') == 'false':
|
||||||
|
page.locator('#drawerOpen').click()
|
||||||
|
menu(page).get_by_role('button', name='Pin', exact=True).click()
|
||||||
|
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonselected_pin_preserves_selected_running_stream():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
calls, conversations = setup(page)
|
||||||
|
streams = []
|
||||||
|
page.route('https://hope.test/api/runs/r1/events?**', lambda route: streams.append(route))
|
||||||
|
with page.expect_request(lambda request: '/api/runs/r1/events?' in request.url):
|
||||||
|
page.locator('#primarySend').click()
|
||||||
|
expect(page.locator('#cancelRun')).to_be_visible()
|
||||||
|
expect(page.locator('#primaryInput')).to_be_disabled()
|
||||||
|
active = menu(page, 'A saved thought')
|
||||||
|
expect(active.get_by_role('button', name='Pin', exact=True)).to_be_disabled()
|
||||||
|
page.keyboard.press('Escape')
|
||||||
|
menu(page).get_by_role('button', name='Pin', exact=True).click()
|
||||||
|
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
|
||||||
|
expect(page.locator('#cancelRun')).to_be_visible()
|
||||||
|
expect(page.locator('#primaryInput')).to_be_disabled()
|
||||||
|
assert page.evaluate('HopeChat.running') is True
|
||||||
|
assert streams
|
||||||
|
streams[0].fulfill(content_type='text/event-stream', body='id: 1\nevent: done\ndata: {"run":{"id":"r1","status":"completed"}}\n\n')
|
||||||
|
expect(page.locator('#primaryInput')).to_be_enabled()
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_sidebar_patch_does_not_cross_account_epoch():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
calls, conversations = setup(page)
|
||||||
|
pending = []
|
||||||
|
def delayed(route):
|
||||||
|
if route.request.method == 'PATCH': pending.append(route)
|
||||||
|
else: route.fallback()
|
||||||
|
page.route('https://hope.test/api/conversations/c2', delayed)
|
||||||
|
with page.expect_request(lambda request: request.method == 'PATCH'):
|
||||||
|
menu(page).get_by_role('button', name='Pin', exact=True).click()
|
||||||
|
assert pending
|
||||||
|
page.evaluate('HopeAuth.signOut()')
|
||||||
|
pending[0].fulfill(status=409, json={'error':'Old account failure'})
|
||||||
|
expect(page.locator('#loginGate')).to_be_visible()
|
||||||
|
expect(page.locator('#chatNotice')).to_have_text('')
|
||||||
|
expect(page.locator('#recentConversations')).to_be_empty()
|
||||||
|
assert not any(method == 'GET' and path == '/api/conversations/c2' for method, path, body in calls)
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delayed_selected_archive_does_not_invalidate_pending_navigation():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
calls, conversations = setup(page)
|
||||||
|
patches, navigations = [], []
|
||||||
|
def delayed_patch(route):
|
||||||
|
if route.request.method == 'PATCH': patches.append(route)
|
||||||
|
else: route.fallback()
|
||||||
|
page.route('https://hope.test/api/conversations/c1', delayed_patch)
|
||||||
|
page.route('https://hope.test/api/conversations/c2', lambda route: navigations.append(route))
|
||||||
|
menu(page, 'A saved thought').get_by_role('button', name='Archive', exact=True).click()
|
||||||
|
with page.expect_request(lambda request: request.method == 'PATCH'):
|
||||||
|
page.locator('#chatActionSave').click()
|
||||||
|
# Dismiss the pending confirmation, then navigate while its write is still pending.
|
||||||
|
page.locator('#chatActionCancel').click()
|
||||||
|
with page.expect_request(lambda request: urlparse(request.url).path == '/api/conversations/c2'):
|
||||||
|
page.get_by_role('button', name='Another thought', exact=True).click()
|
||||||
|
expect(page.locator('#chatNotice')).to_have_text('Loading conversation…')
|
||||||
|
conversations['c1'].update(archived=True, revision=2)
|
||||||
|
patches[0].fulfill(json={'conversation':conversations['c1']})
|
||||||
|
expect(page.locator('#recentConversations')).not_to_contain_text('A saved thought')
|
||||||
|
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
|
||||||
|
navigations[0].fulfill(json={'conversation':conversations['c2'], 'messages':[], 'runs':[], 'next_cursor':None})
|
||||||
|
expect(page.locator('#chatTitle')).to_have_text('Another thought')
|
||||||
|
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutation_restarts_pagination_in_current_search_workspace_scope():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
calls, conversations = setup(page)
|
||||||
|
queries = []
|
||||||
|
conversations['c2']['workspace_id'] = 'w1'
|
||||||
|
third = dict(conversations['c2'], id='c3', title='Another page')
|
||||||
|
def pages(route):
|
||||||
|
query = parse_qs(urlparse(route.request.url).query)
|
||||||
|
queries.append(query)
|
||||||
|
route.fulfill(json={'conversations':[third] if 'cursor' in query else [conversations['c2']], 'next_cursor':None if 'cursor' in query else 'page2'})
|
||||||
|
page.route('https://hope.test/api/conversations?**', pages)
|
||||||
|
page.get_by_role('button', name='Writing', exact=True).click()
|
||||||
|
with page.expect_response(lambda response: '/api/conversations?' in response.url and 'q=Another' in response.url):
|
||||||
|
page.locator('#conversationSearch').fill('Another')
|
||||||
|
expect(page.locator('#loadMoreConversations')).to_be_visible()
|
||||||
|
page.locator('#loadMoreConversations').click()
|
||||||
|
expect(page.locator('#recentConversations')).to_contain_text('Another page')
|
||||||
|
menu(page).get_by_role('button', name='Pin', exact=True).click()
|
||||||
|
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
|
||||||
|
expect(page.locator('#recentConversations')).not_to_contain_text('Another page')
|
||||||
|
assert queries[-1]['q'] == ['Another']
|
||||||
|
assert queries[-1]['workspace_id'] == ['w1']
|
||||||
|
assert queries[-1]['archived'] == ['false']
|
||||||
|
assert 'cursor' not in queries[-1]
|
||||||
|
expect(page.locator('#loadMoreConversations')).to_be_visible()
|
||||||
|
browser.close()
|
||||||
+25
-2
@@ -95,7 +95,13 @@ function setTasksOpen(open) {
|
|||||||
tasksPanel.hidden = !open; tasksPanel.inert = !open;
|
tasksPanel.hidden = !open; tasksPanel.inert = !open;
|
||||||
tasksPanel.classList.toggle("open", open);
|
tasksPanel.classList.toggle("open", open);
|
||||||
tasksBtn.setAttribute("aria-expanded", String(open));
|
tasksBtn.setAttribute("aria-expanded", String(open));
|
||||||
if (open) closeTasksBtn.focus(); else if (wasOpen) tasksBtn.focus();
|
if (open) {
|
||||||
|
if (document.getElementById('chatSidebar')?.classList.contains('drawer-open')) document.getElementById('drawerClose')?.click();
|
||||||
|
closeTasksBtn.focus();
|
||||||
|
} else if (wasOpen) {
|
||||||
|
const target = tasksBtn.getClientRects().length && !tasksBtn.closest('[inert]') ? tasksBtn : document.getElementById('drawerOpen');
|
||||||
|
target?.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tasksBtn.setAttribute("aria-expanded", "false");
|
tasksBtn.setAttribute("aria-expanded", "false");
|
||||||
tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
|
tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
|
||||||
@@ -727,7 +733,18 @@ modelSelect.addEventListener("change", () => {
|
|||||||
thinkingLevelSelect.addEventListener("change", () => {
|
thinkingLevelSelect.addEventListener("change", () => {
|
||||||
updateSettingsControls(); setSettingsStatus("Unsaved changes. Save to apply them to your account.");
|
updateSettingsControls(); setSettingsStatus("Unsaved changes. Save to apply them to your account.");
|
||||||
});
|
});
|
||||||
retryModelSettingsBtn.addEventListener("click", loadModelSettings);
|
retryModelSettingsBtn.addEventListener("click", async () => {
|
||||||
|
await loadModelSettings();
|
||||||
|
if (!window.HopeAuth?.user) return;
|
||||||
|
const account = accountIdentity(), generation = settingsGeneration;
|
||||||
|
try {await window.HopeFeatures?.refresh();}
|
||||||
|
catch (error) {
|
||||||
|
if (settingsResponseIsCurrent(account, generation)) {
|
||||||
|
retryModelSettingsBtn.hidden = false;
|
||||||
|
setSettingsStatus(`File support could not be checked: ${error.message}. Retry to check again. Image attachments remain blocked.`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
modelSettingsForm.addEventListener("submit", async event => {
|
modelSettingsForm.addEventListener("submit", async event => {
|
||||||
event.preventDefault(); updateSettingsControls();
|
event.preventDefault(); updateSettingsControls();
|
||||||
if (saveModelSettingsBtn.disabled) return;
|
if (saveModelSettingsBtn.disabled) return;
|
||||||
@@ -740,6 +757,12 @@ modelSettingsForm.addEventListener("submit", async event => {
|
|||||||
const data = await HopeAuth.request("/api/settings/model", { method: "PUT", body: JSON.stringify(settings), signal: settingsAbort.signal });
|
const data = await HopeAuth.request("/api/settings/model", { method: "PUT", body: JSON.stringify(settings), signal: settingsAbort.signal });
|
||||||
if (!settingsResponseIsCurrent(account, generation)) return;
|
if (!settingsResponseIsCurrent(account, generation)) return;
|
||||||
savedModelSettings = readSavedSettings(data); renderSavedModelSettings();
|
savedModelSettings = readSavedSettings(data); renderSavedModelSettings();
|
||||||
|
window.HopeFeatures?.refresh().catch(error => {
|
||||||
|
if (settingsResponseIsCurrent(account, generation)) {
|
||||||
|
retryModelSettingsBtn.hidden = false;
|
||||||
|
setSettingsStatus(`Model saved, but file support could not be checked: ${error.message}. Use Retry to check again. Image attachments remain blocked.`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
setSettingsStatus(data.message || "Saved. Your next request will use these account settings; your conversation continues uninterrupted.", "success");
|
setSettingsStatus(data.message || "Saved. Your next request will use these account settings; your conversation continues uninterrupted.", "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!settingsResponseIsCurrent(account, generation)) return;
|
if (!settingsResponseIsCurrent(account, generation)) return;
|
||||||
|
|||||||
+60
-9
@@ -3,10 +3,13 @@
|
|||||||
'use strict';
|
'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 $ = 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, research: false, 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;
|
let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false, accountEpoch = 0, rowMenu;
|
||||||
|
const sidebarWrites = new Set();
|
||||||
|
const selectedLocked = id => id === state.conversation?.id && (state.busy || !!state.stream || !terminal(state.run) || state.voiceActive);
|
||||||
|
function closeRowMenu() {if (rowMenu?.matches(':popover-open')) rowMenu.hidePopover();}
|
||||||
const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status);
|
const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status);
|
||||||
const notice = text => { $('chatNotice').textContent = text; };
|
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; };
|
const button = (text, fn, cls = 'btn btn-ghost') => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.addEventListener('click', () => {const epoch = accountEpoch; Promise.resolve().then(fn).catch(error => {if (epoch === accountEpoch) notice(error.message);});}); return b; };
|
||||||
function controls() {
|
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 || !terminal(state.run) || !!state.stream; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy));
|
$('primaryInput').disabled = !ready || state.busy || !terminal(state.run) || !!state.stream; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy));
|
||||||
@@ -34,7 +37,7 @@
|
|||||||
(open ? $('drawerClose') : $('drawerOpen')).focus();
|
(open ? $('drawerClose') : $('drawerOpen')).focus();
|
||||||
}
|
}
|
||||||
$('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' && rowMenu?.matches(':popover-open')) {closeRowMenu(); return;} 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() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').inert = false; $('drawerBackdrop').hidden = true; } }
|
function adapt() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').inert = false; $('drawerBackdrop').hidden = true; } }
|
||||||
mobile.addEventListener('change', adapt); adapt();
|
mobile.addEventListener('change', adapt); adapt();
|
||||||
@@ -108,10 +111,58 @@
|
|||||||
if (state.workspace) params.set('workspace_id', state.workspace); if (append && state.cursor) params.set('cursor', state.cursor);
|
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;
|
const data = await api('/api/conversations?' + params); if (version !== listVersion || epoch !== state.epoch) return;
|
||||||
if (!append) { $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); }
|
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);}
|
closeRowMenu();
|
||||||
|
for (const conversation of data.conversations) {
|
||||||
|
const row = document.createElement('div'); row.className = 'conversation-row'; row.dataset.conversationId = conversation.id;
|
||||||
|
const b = button(conversation.title || 'Untitled chat', () => open(conversation.id), 'conversation-link'); b.setAttribute('aria-current', conversation.id === state.conversation?.id ? 'page' : 'false');
|
||||||
|
const actions = button('•••', () => showRowMenu(conversation, actions), 'conversation-row-actions'); actions.setAttribute('aria-label', 'Actions for ' + (conversation.title || 'Untitled chat')); actions.setAttribute('aria-haspopup', 'dialog'); actions.setAttribute('aria-expanded', 'false');
|
||||||
|
row.append(b, actions); $(conversation.pinned ? 'pinnedConversations' : 'recentConversations').append(row);
|
||||||
|
}
|
||||||
state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor;
|
state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor;
|
||||||
}
|
}
|
||||||
|
function showRowMenu(conversation, trigger) {
|
||||||
|
closeRowMenu(); rowMenu?.remove();
|
||||||
|
const menu = document.createElement('div'); rowMenu = menu; menu.id = 'conversationRowActions'; trigger.setAttribute('aria-controls', menu.id); menu.className = 'conversation-row-menu'; menu.popover = 'auto'; menu.setAttribute('role', 'dialog'); menu.setAttribute('aria-label', 'Actions for ' + (conversation.title || 'Untitled chat'));
|
||||||
|
const epoch = accountEpoch, user = HopeAuth.user?.id;
|
||||||
|
const current = () => epoch === accountEpoch && user === HopeAuth.user?.id;
|
||||||
|
const pin = button(conversation.pinned ? 'Unpin' : 'Pin', async () => {closeRowMenu(); if (current()) await updateConversation(conversation, {pinned: !conversation.pinned});});
|
||||||
|
const rename = button('Rename', () => {closeRowMenu(); if (current()) dialog('Rename conversation', conversation.title, title => updateConversation(conversation, {title}));});
|
||||||
|
const archive = button(conversation.archived ? 'Unarchive' : 'Archive', () => {closeRowMenu(); if (current()) dialog(conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', () => updateConversation(conversation, {archived: !conversation.archived}), 'The conversation is retained. Only its archive status changes.', false);});
|
||||||
|
for (const b of [pin, rename, archive]) b.disabled = selectedLocked(conversation.id) || sidebarWrites.has(conversation.id);
|
||||||
|
menu.append(pin, rename, archive); $('chatSidebar').append(menu);
|
||||||
|
menu.addEventListener('toggle', () => {const expanded = menu.matches(':popover-open'); trigger.setAttribute('aria-expanded', String(expanded)); if (!expanded && trigger.isConnected && menu.contains(document.activeElement)) trigger.focus();});
|
||||||
|
menu.showPopover(); trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
// The native top layer escapes the scrolling sidebar; clamp the menu to the viewport.
|
||||||
|
const rect = trigger.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.margin = '0';
|
||||||
|
menu.style.left = Math.max(8, Math.min(rect.right - menu.offsetWidth, innerWidth - menu.offsetWidth - 8)) + 'px';
|
||||||
|
menu.style.top = Math.max(8, Math.min(rect.bottom, innerHeight - menu.offsetHeight - 8)) + 'px';
|
||||||
|
menu.querySelector('button:not(:disabled)')?.focus();
|
||||||
|
}
|
||||||
|
async function updateConversation(conversation, patch) {
|
||||||
|
const epoch = accountEpoch, selectionEpoch = state.epoch, user = HopeAuth.user?.id, cid = conversation.id;
|
||||||
|
const current = () => epoch === accountEpoch && user === HopeAuth.user?.id;
|
||||||
|
if (!user || sidebarWrites.has(cid)) return;
|
||||||
|
if (selectedLocked(cid)) throw new Error('Wait until this conversation is idle before changing it.');
|
||||||
|
sidebarWrites.add(cid);
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
try {data = await api('/api/conversations/' + enc(cid), 'PATCH', {revision: conversation.revision, ...patch});}
|
||||||
|
catch (error) {
|
||||||
|
if (!current()) return;
|
||||||
|
let refreshed = false;
|
||||||
|
try {const latest = await api('/api/conversations/' + enc(cid)); if (!current()) return; Object.assign(conversation, latest.conversation); if (selectionEpoch === state.epoch && cid === state.conversation?.id) selected(latest.conversation); await list(); refreshed = true;} catch {/* Keep the original mutation failure visible even if refresh also fails. */}
|
||||||
|
if (!current()) return;
|
||||||
|
throw new Error(error.message + (refreshed ? ' Conversation refreshed. Review the latest version and try again.' : ' Could not refresh the conversation. Retry when the connection is available.'));
|
||||||
|
}
|
||||||
|
if (!current()) return;
|
||||||
|
Object.assign(conversation, data.conversation);
|
||||||
|
if (selectionEpoch === state.epoch && cid === state.conversation?.id) {if ('archived' in patch && !selectedLocked(cid)) reset(); else selected(data.conversation);}
|
||||||
|
// Restart the current search/workspace/archive page: writes can change ordering and cursors.
|
||||||
|
await list();
|
||||||
|
} finally {if (current()) sidebarWrites.delete(cid);}
|
||||||
|
}
|
||||||
async function open(id) {
|
async function open(id) {
|
||||||
|
closeRowMenu();
|
||||||
if (state.voiceActive && id !== state.conversation?.id) {notice('End voice before opening another conversation. Your current thread stays here.'); return;}
|
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;
|
||||||
@@ -123,15 +174,15 @@
|
|||||||
$('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));
|
||||||
async function update(patch) {const epoch = state.epoch; let data; try {data = await api('/api/conversations/' + enc(state.conversation.id), 'PATCH', {revision: state.conversation.revision, ...patch});} catch (error) {await refreshSelected(false); await list(); throw new Error(error.message + ' Conversation refreshed. Review the latest version and try again.');} if (epoch !== state.epoch) return; selected(data.conversation); await list();}
|
async function update(patch) {if (state.conversation) await updateConversation(state.conversation, patch);}
|
||||||
$('pinConversation').onclick = () => update({pinned: !state.conversation.pinned}).catch(e => notice(e.message));
|
$('pinConversation').onclick = () => update({pinned: !state.conversation.pinned}).catch(e => notice(e.message));
|
||||||
function dialog(title, value, fn, description = '', needsValue = true) {
|
function dialog(title, value, fn, description = '', needsValue = true) {
|
||||||
$('chatWorkspaceSelect').hidden = true; $('chatWorkspaceLabel').hidden = true; if ($('conversationActions').matches(':popover-open')) $('conversationActions').hidePopover(); action = fn; $('chatActionTitle').textContent = title; $('chatActionDescription').textContent = description; $('chatActionValue').value = value; $('chatActionValue').hidden = !needsValue; $('chatActionLabel').hidden = !needsValue; $('chatActionValue').required = needsValue; $('chatActionError').textContent = ''; $('chatActionDialog').showModal();
|
$('chatWorkspaceSelect').hidden = true; $('chatWorkspaceLabel').hidden = true; if ($('conversationActions').matches(':popover-open')) $('conversationActions').hidePopover(); const epoch = accountEpoch; action = value => epoch === accountEpoch ? fn(value) : undefined; $('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();
|
$('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;} };
|
$('chatActionForm').onsubmit = async event => {event.preventDefault(); const epoch = accountEpoch; $('chatActionSave').disabled = true; try {await action($('chatActionValue').value.trim()); if (epoch === accountEpoch) $('chatActionDialog').close();} catch (e) {if (epoch === accountEpoch) $('chatActionError').textContent = e.message;} finally {if (epoch === accountEpoch) $('chatActionSave').disabled = false;} };
|
||||||
$('renameConversation').onclick = () => dialog('Rename conversation', state.conversation.title, title => update({title}));
|
$('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);
|
$('archiveConversation').onclick = () => dialog(state.conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', () => update({archived: !state.conversation.archived}), '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.');}
|
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));
|
$('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;};
|
$('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;};
|
||||||
@@ -209,7 +260,7 @@
|
|||||||
async function sync() {if (syncBusy || !HopeAuth.user || document.hidden) return; syncBusy = true; const epoch = state.epoch; try {const data = await api('/api/sync?after=' + state.syncCursor); if (epoch !== state.epoch) return; state.syncCursor = data.cursor; if (!data.changes?.length) return; for (const change of data.changes) {if (change.kind === 'preferences' && change.data && !$('preferencesDialog').open && !(window.HopeSettings?.isOpen && HopeSettings.section === 'appearance')) {state.preferences = change.data; theme(change.data);} if (change.kind === 'conversation' && change.id === state.conversation?.id && change.deleted) {reset(); notice('This conversation was deleted on another device.');}} await Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), refreshSelected()]);} catch (error) {/* A subsequent foreground poll retries; never discard the draft. */} finally {syncBusy = false;}}
|
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(); $('libraryDialog').close(); theme({palette: 'opal', appearance: 'system'});}
|
if (changed || !user) {++accountEpoch; closeRowMenu(); rowMenu?.remove(); rowMenu = null; sidebarWrites.clear(); $('chatActionSave').disabled = false; clearTimeout(searchTimer); clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; state.research = false; $('researchMode').hidden = true; $('researchModeLabel').hidden = true; $('researchMode').value = 'normal'; reset(); state.workspace = null; state.workspaces = []; state.preferences = null; $('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);});
|
||||||
|
|||||||
+119
-46
@@ -1,11 +1,40 @@
|
|||||||
// Account-scoped advanced chat controls. Server capability flags are authoritative.
|
// Account-scoped advanced chat controls. Server capability flags are authoritative.
|
||||||
(() => {
|
(() => {
|
||||||
'use strict';
|
'use strict';
|
||||||
const state = {epoch: 0, user: null, capabilities: {}, attachments: [], controllers: new Set(), urls: new Set(), initialized: false};
|
const state = {epoch: 0, draftEpoch: 0, capabilityGeneration: 0, user: null, capabilities: {}, attachments: [], controllers: new Set(), urls: new Set(), initialized: false};
|
||||||
const enc = encodeURIComponent, $ = id => document.getElementById(id);
|
const enc = encodeURIComponent, $ = id => document.getElementById(id);
|
||||||
let options = {}, toolbar, previews, panel, status, artifactEditor, taskEditor, memoryEditor;
|
let options = {}, toolbar, previews, panel, status, composerStatus, artifactEditor, taskEditor, memoryEditor;
|
||||||
const node = (tag, text, cls) => {const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (cls) el.className = cls; return el;};
|
const node = (tag, text, cls) => {const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (cls) el.className = cls; return el;};
|
||||||
const message = text => {if (status) status.textContent = text;};
|
let statusTarget;
|
||||||
|
const message = (text, context = 'section') => {if (status) status.textContent = text; if (context === 'composer') {if (composerStatus) composerStatus.textContent = text;} else if (statusTarget) statusTarget.textContent = text; if (!text) {document.querySelectorAll('.feature-status').forEach(el => {el.textContent = '';}); if (composerStatus) composerStatus.textContent = '';}};
|
||||||
|
let fieldSequence = 0;
|
||||||
|
const extractionBlocked = file => ['failed', 'unavailable', 'encrypted'].includes(file.extraction_status);
|
||||||
|
const imageFile = file => (file.mime || '').startsWith('image/') || file.extraction_status === 'image';
|
||||||
|
function attachmentProblem(file) {
|
||||||
|
if (!state.user || state.capabilities.attachments !== true) return 'Attachments are unavailable for this account.';
|
||||||
|
if (extractionBlocked(file)) return `Text could not be extracted from ${file.filename}. Use a UTF-8 text file instead.`;
|
||||||
|
if (imageFile(file) && state.capabilities.image_input !== true) return 'Image input is not available for the selected model.';
|
||||||
|
if (file.mime && !(state.capabilities.attachment_mimes || []).includes(file.mime)) return 'Unsupported file type: ' + file.filename;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function focusComposer() {
|
||||||
|
$('libraryDialog')?.close();
|
||||||
|
(options.composerInput || $('primaryInput') || $('chatInput'))?.focus();
|
||||||
|
}
|
||||||
|
async function useFile(file) {
|
||||||
|
const problem = attachmentProblem(file); if (problem) throw new Error(problem);
|
||||||
|
if (state.attachments.some(item => item.attachment?.id === file.id)) {message(file.filename + ' is already queued. Send your message when ready.', 'composer'); focusComposer(); return;}
|
||||||
|
const maximum = state.capabilities.attachments_per_message || 8;
|
||||||
|
if (state.attachments.length >= maximum) throw new Error(`Attach at most ${maximum} files per message.`);
|
||||||
|
const draftEpoch = state.draftEpoch;
|
||||||
|
const current = (await request('/api/attachments/' + enc(file.id))).attachment;
|
||||||
|
if (draftEpoch !== state.draftEpoch) return;
|
||||||
|
const currentProblem = attachmentProblem(current); if (currentProblem) throw new Error(currentProblem);
|
||||||
|
if (state.attachments.some(item => item.attachment?.id === current.id)) return;
|
||||||
|
if (state.attachments.length >= maximum) throw new Error(`Attach at most ${maximum} files per message.`);
|
||||||
|
state.attachments.push({file: {name: current.filename, size: current.size}, attachment: current, status: 'ready', error: ''});
|
||||||
|
changed(); message(current.filename + ' queued. Send your message when ready.', 'composer'); focusComposer();
|
||||||
|
}
|
||||||
function button(label, fn) {const el = node('button', label, 'btn btn-ghost'); el.type = 'button'; el.addEventListener('click', async () => {el.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message);} finally {el.disabled = false;}}); return el;}
|
function button(label, fn) {const el = node('button', label, 'btn btn-ghost'); el.type = 'button'; el.addEventListener('click', async () => {el.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message);} finally {el.disabled = false;}}); return el;}
|
||||||
async function request(path, method = 'GET', body, extra = {}) {
|
async function request(path, method = 'GET', body, extra = {}) {
|
||||||
const epoch = state.epoch, controller = new AbortController(); state.controllers.add(controller);
|
const epoch = state.epoch, controller = new AbortController(); state.controllers.add(controller);
|
||||||
@@ -17,7 +46,7 @@
|
|||||||
}
|
}
|
||||||
function changed() {renderAttachments(); window.dispatchEvent(new CustomEvent('hope:attachments-changed', {detail: {ids: attachmentIDs(), pending: hasPendingUploads()}}));}
|
function changed() {renderAttachments(); window.dispatchEvent(new CustomEvent('hope:attachments-changed', {detail: {ids: attachmentIDs(), pending: hasPendingUploads()}}));}
|
||||||
function attachmentIDs() {return state.attachments.filter(item => item.attachment && item.status === 'ready').map(item => item.attachment.id);}
|
function attachmentIDs() {return state.attachments.filter(item => item.attachment && item.status === 'ready').map(item => item.attachment.id);}
|
||||||
function hasPendingUploads() {return state.attachments.some(item => item.status !== 'ready');}
|
function hasPendingUploads() {return state.attachments.some(item => item.status !== 'ready' || !!attachmentProblem(item.attachment || {filename: item.file.name, mime: mime(item.file)}));}
|
||||||
function dispose(item) {item.controller?.abort(); if (item.url) {URL.revokeObjectURL(item.url); state.urls.delete(item.url);} }
|
function dispose(item) {item.controller?.abort(); if (item.url) {URL.revokeObjectURL(item.url); state.urls.delete(item.url);} }
|
||||||
function clearAttachmentsAfterSend(ids = attachmentIDs()) {const selected = new Set(ids); state.attachments = state.attachments.filter(item => {if (selected.has(item.attachment?.id)) {dispose(item); return false;} return true;}); changed();}
|
function clearAttachmentsAfterSend(ids = attachmentIDs()) {const selected = new Set(ids); state.attachments = state.attachments.filter(item => {if (selected.has(item.attachment?.id)) {dispose(item); return false;} return true;}); changed();}
|
||||||
function clearAll() {for (const item of state.attachments) dispose(item); state.attachments = []; changed();}
|
function clearAll() {for (const item of state.attachments) dispose(item); state.attachments = []; changed();}
|
||||||
@@ -35,27 +64,30 @@
|
|||||||
finally {if (epoch === state.epoch) changed();}
|
finally {if (epoch === state.epoch) changed();}
|
||||||
}
|
}
|
||||||
async function addFiles(files) {
|
async function addFiles(files) {
|
||||||
if (!state.user || state.capabilities.attachments !== true) {message('Attachments are unavailable for this account.'); return;}
|
const context = $('libraryDialog')?.open ? 'section' : 'composer', accepted = [], errors = [];
|
||||||
|
if (!state.user || state.capabilities.attachments !== true) {message('Attachments are unavailable for this account.', context); return;}
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
const maximum = state.capabilities.attachments_per_message || 8;
|
const maximum = state.capabilities.attachments_per_message || 8;
|
||||||
if (state.attachments.length >= maximum) {message(`Attach at most ${maximum} files per message.`); break;}
|
if (state.attachments.length >= maximum) {errors.push(`Attach at most ${maximum} files per message.`); break;}
|
||||||
if (!(state.capabilities.attachment_mimes || []).includes(mime(file))) {message('Unsupported file type: ' + file.name); continue;}
|
if (!(state.capabilities.attachment_mimes || []).includes(mime(file))) {errors.push('Unsupported file type: ' + file.name); continue;}
|
||||||
if (mime(file).startsWith('image/') && state.capabilities.image_input !== true) {message('Image input is not available for the selected model.'); continue;}
|
if (mime(file).startsWith('image/') && state.capabilities.image_input !== true) {errors.push('Image input is not available for the selected model.'); continue;}
|
||||||
if (!file.size || file.size > (state.capabilities.upload_bytes || 8 * 1024 * 1024)) {message('File is empty or exceeds the upload limit: ' + file.name); continue;}
|
if (!file.size || file.size > (state.capabilities.upload_bytes || 8 * 1024 * 1024)) {errors.push('File is empty or exceeds the upload limit: ' + file.name); continue;}
|
||||||
if (file.name.length > 160 || /[\\/\r\n\x00-\x1F]/.test(file.name)) {message('File name is not supported. Rename the file and retry.'); continue;}
|
if (file.name.length > 160 || /[\\/\r\n\x00-\x1F]/.test(file.name)) {errors.push('File name is not supported. Rename the file and retry.'); continue;}
|
||||||
const item = {file, status: 'uploading', attachment: null, error: ''};
|
const item = {file, status: 'uploading', attachment: null, error: ''};
|
||||||
if (mime(file).startsWith('image/')) {item.url = URL.createObjectURL(file); state.urls.add(item.url);}
|
if (mime(file).startsWith('image/')) {item.url = URL.createObjectURL(file); state.urls.add(item.url);}
|
||||||
state.attachments.push(item); upload(item);
|
state.attachments.push(item); accepted.push(file.name); upload(item);
|
||||||
}
|
}
|
||||||
changed();
|
changed();
|
||||||
|
if (accepted.length) {focusComposer(); message('Uploading ' + accepted.join(', ') + '. Send your message after uploads finish.' + (errors.length ? ' ' + errors.join(' ') : ''), 'composer');}
|
||||||
|
else if (errors.length) message(errors.join(' '), context);
|
||||||
}
|
}
|
||||||
function renderAttachments() {
|
function renderAttachments() {
|
||||||
if (!previews) return; previews.replaceChildren();
|
if (!previews) return; previews.replaceChildren();
|
||||||
for (const item of state.attachments) {
|
for (const item of state.attachments) {
|
||||||
const row = node('div', undefined, 'attachment-preview');
|
const row = node('div', undefined, 'attachment-preview feature-row');
|
||||||
if (item.url) {const image = node('img'); image.src = item.url; image.alt = 'Preview of ' + item.file.name; image.width = 64; image.height = 64; row.append(image);}
|
if (item.url) {const image = node('img'); image.src = item.url; image.alt = 'Preview of ' + item.file.name; image.width = 64; image.height = 64; row.append(image);}
|
||||||
row.append(node('span', `${item.file.name} · ${Math.ceil(item.file.size / 1024)} KB · ${item.status}`));
|
row.append(node('span', `${item.file.name} · ${Math.ceil(item.file.size / 1024)} KB · ${item.status}`));
|
||||||
if (item.error) row.append(node('p', item.error));
|
const problem = item.error || (item.status === 'ready' ? attachmentProblem(item.attachment) : ''); if (problem) row.append(node('p', problem));
|
||||||
if (item.attachment?.extraction_status === 'truncated') row.append(node('p', 'Only the extracted text limit will be sent.'));
|
if (item.attachment?.extraction_status === 'truncated') row.append(node('p', 'Only the extracted text limit will be sent.'));
|
||||||
if (item.status === 'uploading') row.append(button('Cancel upload', () => item.controller.abort()));
|
if (item.status === 'uploading') row.append(button('Cancel upload', () => item.controller.abort()));
|
||||||
if (item.status === 'failed') row.append(button('Retry upload', () => upload(item)));
|
if (item.status === 'failed') row.append(button('Retry upload', () => upload(item)));
|
||||||
@@ -63,30 +95,51 @@
|
|||||||
previews.append(row);
|
previews.append(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function field(form, label, kind = 'input', value = '') {const wrap = node('label', label), input = node(kind); input.value = value; wrap.append(input); form.append(wrap); return input;}
|
function field(form, label, kind = 'input', value = '', hint = '') {
|
||||||
function section(title) {const detail = node('details'), summary = node('summary', title), body = node('div'); detail.append(summary, body); panel.append(detail); return {detail, body};}
|
const wrap = node('div', undefined, 'feature-field'), input = node(kind), id = 'feature-field-' + ++fieldSequence;
|
||||||
function formSubmit(form, fn) {form.addEventListener('submit', async event => {event.preventDefault(); const submit = form.querySelector('[type="submit"]'); submit.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message + ' Your edits are retained.');} finally {submit.disabled = false;}});}
|
input.id = id; input.value = value; const caption = node('label', label); caption.htmlFor = id; wrap.append(caption, input);
|
||||||
function submitButton(form, label) {const el = node('button', label, 'btn btn-primary'); el.type = 'submit'; form.append(el); return el;}
|
if (hint) {const help = node('p', hint, 'feature-hint'); help.id = id + '-hint'; input.setAttribute('aria-describedby', help.id); wrap.append(help);}
|
||||||
function confirmAction(container, label, description, fn) {const details = node('details'), summary = node('summary', label); details.append(summary, node('p', description), button('Confirm ' + label.toLowerCase(), fn)); container.append(details);}
|
form.append(wrap); return input;
|
||||||
function download(path, label) {const link = node('a', label, 'btn btn-ghost'); link.href = path; link.setAttribute('download', ''); return link;}
|
}
|
||||||
|
function section(title) {
|
||||||
|
const detail = node('details', undefined, 'feature-section'), summary = node('summary', title), body = node('div', undefined, 'feature-section-body');
|
||||||
|
const localStatus = node('p', undefined, 'feature-status'); localStatus.setAttribute('role', 'status');
|
||||||
|
detail.addEventListener('click', () => {statusTarget = localStatus;}, true); detail.addEventListener('submit', () => {statusTarget = localStatus;}, true);
|
||||||
|
const unavailable = node('p', title + ' are unavailable for this account.', 'feature-empty'); unavailable.hidden = true;
|
||||||
|
detail.append(summary, localStatus, body); panel.append(detail, unavailable); return {detail, body, unavailable};
|
||||||
|
}
|
||||||
|
function rowParts() {const row = node('div', undefined, 'feature-row'), main = node('div', undefined, 'feature-row-main'), actions = node('div', undefined, 'feature-row-actions'); row.append(main, actions); return {row, main, actions};}
|
||||||
|
function listBody(area, intro, actions, empty) {
|
||||||
|
const body = area.body, tools = node('div', undefined, 'feature-toolbar'), list = node('div', undefined, 'feature-list'); tools.append(...actions);
|
||||||
|
body.replaceChildren(node('p', intro, 'feature-intro'), tools, list); if (empty) list.append(node('p', empty, 'feature-empty')); return list;
|
||||||
|
}
|
||||||
|
function formSubmit(form, fn) {form.addEventListener('submit', async event => {event.preventDefault(); const submit = form.querySelector('[type="submit"]'); submit.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message + ' Your edits are retained.' + (form.dataset.memory === 'true' && (error.status === 409 || /conflict/i.test(error.message)) ? ' Compare latest saved memory, review its content, then explicitly accept the reviewed revision before saving again.' : ''));} finally {submit.disabled = false;}});}
|
||||||
|
function submitButton(form, label) {const el = node('button', label, 'btn btn-primary'); el.type = 'submit'; const actions = node('div', undefined, 'feature-form-actions'); actions.append(el); form.append(actions); return el;}
|
||||||
|
function confirmAction(container, label, description, fn) {const details = node('details', undefined, 'feature-confirm'), summary = node('summary', label.startsWith('Delete ') && label !== 'Delete task' && label !== 'Delete draft' ? 'Delete' : label); summary.setAttribute('aria-label', label); details.append(summary, node('p', description), button('Confirm ' + label.toLowerCase(), fn)); container.append(details);}
|
||||||
|
function download(path, label) {const link = node('a', label.startsWith('Download ') ? 'Download' : label, 'btn btn-ghost'); if (label.startsWith('Download ')) link.setAttribute('aria-label', label); link.href = path; link.setAttribute('download', ''); return link;}
|
||||||
const filesSection = {value: null}, artifactSection = {value: null}, taskSection = {value: null}, memorySection = {value: null};
|
const filesSection = {value: null}, artifactSection = {value: null}, taskSection = {value: null}, memorySection = {value: null};
|
||||||
async function loadFiles() {
|
async function loadFiles() {
|
||||||
const data = await request('/api/attachments'), body = filesSection.value.body; body.replaceChildren(button('Refresh files', loadFiles));
|
const data = await request('/api/attachments');
|
||||||
if (!data.attachments?.length) body.append(node('p', 'Uploaded files stay in your private account until you delete them.'));
|
const list = listBody(filesSection.value, 'Files are private account uploads you can reuse in conversations. Uploading or choosing a file only queues it; nothing is sent until you send a message.', [button('Upload files', () => {if (state.capabilities.attachments !== true) throw new Error('Attachments are unavailable for this account.'); toolbar.querySelector('input[type="file"]').click();}), button('Refresh files', loadFiles)], !data.attachments?.length ? 'No uploaded files yet. Upload a document or supported image to use it in a conversation. Files stay in your account until you delete them.' : '');
|
||||||
for (const file of data.attachments || []) {const row = node('div'); row.append(node('span', `${file.filename} · ${Math.ceil(file.size / 1024)} KB · ${file.extraction_status}`), download('/api/attachments/' + enc(file.id) + '/content', 'Download ' + file.filename)); confirmAction(row, 'Delete ' + file.filename, "Deleting removes file bytes; old chat attachment references won't work.", async () => {await request('/api/attachments/' + enc(file.id), 'DELETE'); state.attachments = state.attachments.filter(item => {if (item.attachment?.id === file.id) {dispose(item); return false;} return true;}); changed(); await loadFiles(); message('File deleted.');}); body.append(row);}
|
for (const file of data.attachments || []) {
|
||||||
|
const {row, main, actions} = rowParts(); main.append(node('h3', file.filename), node('p', `${Math.ceil(file.size / 1024)} KB · ${file.extraction_status}`));
|
||||||
|
const problem = attachmentProblem(file); if (problem) main.append(node('p', problem));
|
||||||
|
actions.append(button('Use in conversation', () => useFile(file)), download('/api/attachments/' + enc(file.id) + '/content', 'Download ' + file.filename));
|
||||||
|
confirmAction(actions, 'Delete ' + file.filename, `Delete “${file.filename}” from your account? Deleting removes file bytes; old chat attachment references won't work. Your conversations are not deleted.`, async () => {await request('/api/attachments/' + enc(file.id), 'DELETE'); state.attachments = state.attachments.filter(item => {if (item.attachment?.id === file.id) {dispose(item); return false;} return true;}); changed(); await loadFiles(); message(file.filename + ' deleted.');}); list.append(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function loadArtifacts() {
|
async function loadArtifacts() {
|
||||||
const data = await request('/api/artifacts'); const body = artifactSection.value.body; body.replaceChildren(button('New artifact', () => editArtifact(null)), button('Refresh artifacts', loadArtifacts));
|
const data = await request('/api/artifacts');
|
||||||
if (!data.artifacts?.length) body.append(node('p', 'Save a draft, note or code snippet to keep versions across devices.'));
|
const list = listBody(artifactSection.value, 'Saved drafts (artifacts) are editable notes, writing and code with saved versions. They are separate from uploaded files and are not automatically attached to a conversation.', [button('New draft', () => editArtifact(null)), button('Refresh drafts', loadArtifacts)], !data.artifacts?.length ? 'No saved drafts yet. Create a draft, note or code snippet to keep versions across devices.' : '');
|
||||||
for (const artifact of data.artifacts || []) {const row = node('div'); row.append(node('span', artifact.title), button('Edit ' + artifact.title, async () => editArtifact((await request('/api/artifacts/' + enc(artifact.id))).artifact)), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export')); confirmAction(row, 'Delete artifact', 'This removes the artifact and its saved versions.', async () => {await request('/api/artifacts/' + enc(artifact.id), 'DELETE'); await loadArtifacts(); message('Artifact deleted.');}); body.append(row);}
|
for (const artifact of data.artifacts || []) {const {row, main, actions} = rowParts(); main.append(node('h3', artifact.title)); actions.append(button('Edit ' + artifact.title, async () => editArtifact((await request('/api/artifacts/' + enc(artifact.id))).artifact)), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export')); confirmAction(actions, 'Delete draft', `Delete “${artifact.title}”? This removes this saved draft and its versions, not conversations or uploaded files.`, async () => {await request('/api/artifacts/' + enc(artifact.id), 'DELETE'); await loadArtifacts(); message(artifact.title + ' deleted.');}); list.append(row);}
|
||||||
}
|
}
|
||||||
function editArtifact(artifact) {
|
function editArtifact(artifact) {
|
||||||
artifactEditor.replaceChildren(); const form = node('form'), heading = node('h3', artifact ? 'Edit artifact' : 'New artifact');
|
artifactEditor.replaceChildren(); const form = node('form', undefined, 'feature-form'), heading = node('h3', artifact ? 'Edit draft' : 'New draft');
|
||||||
const title = field(form, 'Title', 'input', artifact?.title || ''); title.required = true; title.maxLength = 160;
|
const title = field(form, 'Title', 'input', artifact?.title || ''); title.required = true; title.maxLength = 160;
|
||||||
const kind = field(form, 'Format', 'select'); for (const format of ['markdown', 'text', 'code']) {const option = node('option', format); option.value = format; kind.append(option);} kind.value = artifact?.kind || 'markdown';
|
const kind = field(form, 'Format', 'select'); for (const format of ['markdown', 'text', 'code']) {const option = node('option', format); option.value = format; kind.append(option);} kind.value = artifact?.kind || 'markdown';
|
||||||
const content = field(form, 'Content', 'textarea', artifact?.content || ''); content.rows = 12;
|
const content = field(form, 'Content', 'textarea', artifact?.content || ''); content.rows = 12;
|
||||||
const preview = node('pre', artifact?.content || ''); content.addEventListener('input', () => {preview.textContent = content.value;});
|
const preview = node('pre', artifact?.content || ''); content.addEventListener('input', () => {preview.textContent = content.value;});
|
||||||
submitButton(form, artifact ? 'Save new version' : 'Create artifact'); form.append(button('Close editor', () => artifactEditor.replaceChildren()));
|
submitButton(form, artifact ? 'Save new version' : 'Create draft'); form.append(button('Close editor', () => artifactEditor.replaceChildren()));
|
||||||
if (artifact) {
|
if (artifact) {
|
||||||
const versions = field(form, 'Saved version', 'select'); for (let revision = artifact.revision; revision >= 1; revision--) {const option = node('option', 'Version ' + revision); option.value = revision; versions.append(option);}
|
const versions = field(form, 'Saved version', 'select'); for (let revision = artifact.revision; revision >= 1; revision--) {const option = node('option', 'Version ' + revision); option.value = revision; versions.append(option);}
|
||||||
const versionExport = download('/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value), 'Export selected saved version'); versions.addEventListener('change', () => {versionExport.href = '/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value);}); form.append(button('View saved version', async () => {const saved = (await request('/api/artifacts/' + enc(artifact.id) + '?revision=' + enc(versions.value))).artifact; preview.textContent = saved.content; message('Viewing version ' + saved.revision + '. Your current edits are unchanged.');}), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export latest saved version'), versionExport);
|
const versionExport = download('/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value), 'Export selected saved version'); versions.addEventListener('change', () => {versionExport.href = '/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value);}); form.append(button('View saved version', async () => {const saved = (await request('/api/artifacts/' + enc(artifact.id) + '?revision=' + enc(versions.value))).artifact; preview.textContent = saved.content; message('Viewing version ' + saved.revision + '. Your current edits are unchanged.');}), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export latest saved version'), versionExport);
|
||||||
@@ -95,34 +148,52 @@
|
|||||||
artifactEditor.append(heading, form, node('h4', 'Safe text preview'), preview); title.focus();
|
artifactEditor.append(heading, form, node('h4', 'Safe text preview'), preview); title.focus();
|
||||||
}
|
}
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
const data = await request('/api/tasks'), body = taskSection.value.body; body.replaceChildren(button('New scheduled task', () => editTask(null)), button('Refresh tasks', loadTasks));
|
const data = await request('/api/tasks');
|
||||||
if (!data.tasks?.length) body.append(node('p', 'Schedule a prompt for your private assistant. Results stay local.'));
|
const list = listBody(taskSection.value, 'Scheduled tasks run a prompt with your private assistant. Delivery stays local; no external recipients or notifications are configured here.', [button('New scheduled task', () => editTask(null)), button('Refresh tasks', loadTasks)], !data.tasks?.length ? 'No scheduled tasks yet. Create one paused to review its prompt and schedule before enabling it.' : '');
|
||||||
for (const task of data.tasks || []) {const row = node('div'); row.append(node('h3', task.name), node('p', String(task.schedule || 'Schedule not supplied') + ' · ' + (task.enabled === true ? 'Enabled' : task.enabled === false ? 'Paused' : 'Status not supplied')), node('p', task.prompt), button('Edit task', () => editTask(task)), button(task.enabled === true ? 'Pause' : task.enabled === false ? 'Resume' : 'Refresh task status', async () => {if (typeof task.enabled !== 'boolean') {await loadTasks(); return;} await request('/api/tasks/' + enc(task.id) + '/' + (task.enabled ? 'pause' : 'resume'), 'POST'); await loadTasks(); message(task.enabled ? 'Task paused.' : 'Task resumed.');}), button('Run now', async () => {await request('/api/tasks/' + enc(task.id) + '/run', 'POST'); await loadTasks(); message('Task run requested. Refresh tasks to check its state.');})); confirmAction(row, 'Delete task', 'This removes the scheduled task, not your conversations.', async () => {await request('/api/tasks/' + enc(task.id), 'DELETE'); await loadTasks(); message('Task deleted.');}); body.append(row);}
|
for (const task of data.tasks || []) {
|
||||||
|
const {row, main, actions} = rowParts(); main.append(node('h3', task.name || 'Scheduled task'), node('p', String(task.schedule || 'Schedule not supplied') + ' · ' + (task.enabled === true ? 'Enabled' : task.enabled === false ? 'Paused' : 'Status not supplied')), node('p', task.prompt || 'Prompt not supplied'));
|
||||||
|
actions.append(button('Edit task', () => editTask(task)), button(task.enabled === true ? 'Pause' : task.enabled === false ? 'Resume' : 'Refresh task status', async () => {if (typeof task.enabled !== 'boolean') {await loadTasks(); return;} await request('/api/tasks/' + enc(task.id) + '/' + (task.enabled ? 'pause' : 'resume'), 'POST'); await loadTasks(); message(task.enabled ? 'Task paused.' : 'Task resumed.');}), button('Run now', async () => {await request('/api/tasks/' + enc(task.id) + '/run', 'POST'); await loadTasks(); message('Task run requested. Refresh tasks to check its state.');}));
|
||||||
|
confirmAction(actions, 'Delete task', `Delete “${task.name || 'Scheduled task'}”? This removes the scheduled task, not your conversations.`, async () => {await request('/api/tasks/' + enc(task.id), 'DELETE'); await loadTasks(); message('Task deleted.');}); list.append(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function editTask(task) {
|
function editTask(task) {
|
||||||
taskEditor.replaceChildren(); const form = node('form'), title = field(form, 'Task name', 'input', task?.name || ''), schedule = field(form, 'Schedule', 'input', task?.schedule || ''), prompt = field(form, 'Prompt', 'textarea', task?.prompt || ''), repeat = field(form, 'Repeat count (optional)', 'input', task?.repeat || '');
|
taskEditor.replaceChildren(); const form = node('form', undefined, 'feature-form');
|
||||||
|
const title = field(form, 'Task name', 'input', task?.name || '', 'A short name to recognize this task, for example “Review my notes”.');
|
||||||
|
const schedule = field(form, 'Schedule', 'input', task?.schedule || '', 'Enter a schedule accepted by your assistant, for example cron: 0 9 * * *. The server validates the schedule; timezone and next run are not inferred here.');
|
||||||
|
const prompt = field(form, 'Prompt', 'textarea', task?.prompt || '', 'What should the assistant do when this task runs? For example: “Summarize the priorities in my saved notes.”'); prompt.rows = 5;
|
||||||
|
const repeat = field(form, 'Repeat count (optional)', 'input', task?.repeat ?? '', 'Optional positive whole-number repeat value passed to the scheduler. Leave blank to omit it; no unlimited-run behavior is assumed.');
|
||||||
title.maxLength = 200; schedule.maxLength = 200; prompt.maxLength = 5000; title.required = schedule.required = prompt.required = true; repeat.type = 'number'; repeat.min = 1; repeat.step = 1;
|
title.maxLength = 200; schedule.maxLength = 200; prompt.maxLength = 5000; title.required = schedule.required = prompt.required = true; repeat.type = 'number'; repeat.min = 1; repeat.step = 1;
|
||||||
form.append(node('p', 'Use a schedule accepted by your assistant, such as a cron expression. Invalid schedules are rejected by the server. Delivery stays local.'));
|
const paused = field(form, task ? 'Enabled' : 'Create paused', 'input', '', task ? 'Uncheck to pause this task.' : 'Checked by default: review the task before choosing Resume. Uncheck to create it enabled.'); paused.type = 'checkbox'; paused.checked = task ? task.enabled === true : true; paused.parentElement.classList.add('feature-checkbox');
|
||||||
const paused = field(form, task ? 'Enabled' : 'Create paused'); paused.type = 'checkbox'; paused.checked = task ? !!task.enabled : true;
|
|
||||||
submitButton(form, task ? 'Save task' : 'Create task'); form.append(button('Close task editor', () => taskEditor.replaceChildren()));
|
submitButton(form, task ? 'Save task' : 'Create task'); form.append(button('Close task editor', () => taskEditor.replaceChildren()));
|
||||||
formSubmit(form, async () => {const data = {name: title.value.trim(), schedule: schedule.value.trim(), prompt: prompt.value.trim(), ...(task ? {enabled: paused.checked} : {paused: paused.checked})}; if (repeat.value) data.repeat = Number(repeat.value); await request('/api/tasks' + (task ? '/' + enc(task.id) : ''), task ? 'PATCH' : 'POST', data); await loadTasks(); taskEditor.replaceChildren(); message(task ? 'Task saved.' : 'Task created.');}); taskEditor.append(node('h3', task ? 'Edit scheduled task' : 'New scheduled task'), form); title.focus();
|
formSubmit(form, async () => {const data = {name: title.value.trim(), schedule: schedule.value.trim(), prompt: prompt.value.trim(), ...(task ? {enabled: paused.checked} : {paused: paused.checked})}; if (repeat.value) data.repeat = Number(repeat.value); await request('/api/tasks' + (task ? '/' + enc(task.id) : ''), task ? 'PATCH' : 'POST', data); await loadTasks(); taskEditor.replaceChildren(); message(task ? 'Task saved.' : 'Task created.');}); taskEditor.append(node('h3', task ? 'Edit scheduled task' : 'New scheduled task'), form); title.focus();
|
||||||
}
|
}
|
||||||
async function loadMemory() {
|
async function loadMemory() {
|
||||||
const data = await request('/api/memory'); memoryEditor.replaceChildren(node('p', 'Saved memory changes may take effect in a later assistant session. Clearing a section does not delete your conversations.'));
|
const data = await request('/api/memory'); memoryEditor.replaceChildren(node('p', 'Memory is saved account context, not conversation history. Changes may take effect in a later assistant session. Clearing a section does not delete your conversations or other account data.', 'feature-intro'));
|
||||||
for (const section of data.sections || []) {
|
for (const section of data.sections || []) {
|
||||||
const form = node('form'), label = section.section === 'user' ? 'About you' : 'Assistant memory', content = field(form, label, 'textarea', section.content); content.rows = 10;
|
const form = node('form', undefined, 'feature-form'), label = section.section === 'user' ? 'About you' : 'Assistant memory', content = field(form, label, 'textarea', section.content, section.section === 'user' ? 'Tell the assistant about your preferences and context. For example: “Call me Sam. I prefer concise answers and work in product design.”' : 'Review durable facts and guidance the assistant keeps between sessions. For example: “Our project is called Atlas; use metric units in project notes.”'); content.rows = 10; form.dataset.memory = 'true';
|
||||||
submitButton(form, 'Save ' + label.toLowerCase()); confirmAction(form, 'Clear ' + label.toLowerCase(), 'This clears this saved section. Conversations and the other section stay unchanged.', async () => {const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: '', revision: section.revision}); section.revision = saved.revision; content.value = ''; message(label + ' cleared.');});
|
submitButton(form, 'Save ' + label.toLowerCase()); confirmAction(form, 'Clear ' + label.toLowerCase(), 'This clears this saved section. Conversations and the other section stay unchanged.', async () => {const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: '', revision: section.revision}); section.revision = saved.revision; content.value = ''; message(label + ' cleared.');});
|
||||||
|
const compare = node('div', undefined, 'feature-editor'); compare.hidden = true;
|
||||||
|
form.append(button('Compare latest saved memory', async () => {
|
||||||
|
const latest = (await request('/api/memory')).sections.find(item => item.section === section.section);
|
||||||
|
if (!latest) throw new Error('Saved memory section is unavailable. Your edits are retained.');
|
||||||
|
compare.hidden = false; compare.replaceChildren(node('h4', 'Latest saved ' + label.toLowerCase()), node('p', 'Your draft above is unchanged. Review the saved text before accepting its revision. Saving after accepting will replace this saved text with your draft.'), node('pre', latest.content), button('Use reviewed revision for next save', () => {section.revision = latest.revision; compare.hidden = true; message('Reviewed revision accepted. Your draft is unchanged; choose Save to replace the saved section.');}));
|
||||||
|
}), compare);
|
||||||
formSubmit(form, async () => {if (new TextEncoder().encode(content.value).length > 65536) throw new Error('Memory section exceeds 64 KB.'); const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: content.value, revision: section.revision}); section.revision = saved.revision; message(label + ' saved.');}); memoryEditor.append(form);
|
formSubmit(form, async () => {if (new TextEncoder().encode(content.value).length > 65536) throw new Error('Memory section exceeds 64 KB.'); const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: content.value, revision: section.revision}); section.revision = saved.revision; message(label + ' saved.');}); memoryEditor.append(form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!state.user) return;
|
if (!state.user) return;
|
||||||
const result = await request('/api/capabilities'); state.capabilities = result.features || result.capabilities || result;
|
const generation = ++state.capabilityGeneration;
|
||||||
|
state.capabilities = {...state.capabilities, image_input: false}; changed();
|
||||||
|
const result = await request('/api/capabilities');
|
||||||
|
if (generation !== state.capabilityGeneration) return;
|
||||||
|
state.capabilities = result.features || result.capabilities || result;
|
||||||
toolbar.hidden = !state.user; const input = toolbar.querySelector('input[type="file"]'); input.accept = (state.capabilities.attachment_mimes || []).filter(type => !type.startsWith('image/') || state.capabilities.image_input === true).join(','); input.disabled = state.capabilities.attachments !== true;
|
toolbar.hidden = !state.user; const input = toolbar.querySelector('input[type="file"]'); input.accept = (state.capabilities.attachment_mimes || []).filter(type => !type.startsWith('image/') || state.capabilities.image_input === true).join(','); input.disabled = state.capabilities.attachments !== true;
|
||||||
toolbar.querySelector('[data-attach]').disabled = input.disabled;
|
toolbar.querySelector('[data-attach]').disabled = input.disabled;
|
||||||
filesSection.value.detail.hidden = state.capabilities.attachments !== true; artifactSection.value.detail.hidden = state.capabilities.artifacts !== true; taskSection.value.detail.hidden = state.capabilities.scheduled_tasks !== true; memorySection.value.detail.hidden = state.capabilities.memory_controls !== true;
|
filesSection.value.detail.hidden = state.capabilities.attachments !== true; artifactSection.value.detail.hidden = state.capabilities.artifacts !== true; taskSection.value.detail.hidden = state.capabilities.scheduled_tasks !== true; memorySection.value.detail.hidden = state.capabilities.memory_controls !== true;
|
||||||
panel.hidden = !state.user;
|
for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) area.unavailable.hidden = !area.detail.hidden;
|
||||||
const availability = $('featureAvailability'); availability.textContent = [state.capabilities.scheduled_tasks !== true ? 'Scheduled tasks are unavailable.' : '', state.capabilities.memory_controls !== true ? 'Memory controls are unavailable.' : '', state.capabilities.image_input !== true ? 'Image input is unavailable for this model.' : ''].filter(Boolean).join(' ');
|
panel.hidden = !state.user; changed();
|
||||||
|
const availability = $('featureAvailability'); availability.textContent = [state.capabilities.attachments !== true ? 'Files are unavailable.' : '', state.capabilities.artifacts !== true ? 'Saved drafts are unavailable.' : '', state.capabilities.scheduled_tasks !== true ? 'Scheduled tasks are unavailable.' : '', state.capabilities.memory_controls !== true ? 'Memory controls are unavailable.' : '', state.capabilities.image_input !== true ? 'Image input is unavailable for this model.' : ''].filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
function initialize(config = {}) {
|
function initialize(config = {}) {
|
||||||
options = {...options, ...config}; if (state.initialized) return; state.initialized = true;
|
options = {...options, ...config}; if (state.initialized) return; state.initialized = true;
|
||||||
@@ -130,27 +201,29 @@
|
|||||||
if (!toolbar) {toolbar = node('div'); toolbar.id = 'featureActions'; ($('primaryComposer') || document.body).append(toolbar);}
|
if (!toolbar) {toolbar = node('div'); toolbar.id = 'featureActions'; ($('primaryComposer') || document.body).append(toolbar);}
|
||||||
if (!previews) {previews = node('div'); previews.id = 'attachmentPreviews'; toolbar.after(previews);}
|
if (!previews) {previews = node('div'); previews.id = 'attachmentPreviews'; toolbar.after(previews);}
|
||||||
previews.setAttribute('aria-live', 'polite');
|
previews.setAttribute('aria-live', 'polite');
|
||||||
|
composerStatus = $('chatNotice'); if (!composerStatus) {composerStatus = node('p'); composerStatus.id = 'featureComposerStatus'; composerStatus.setAttribute('role', 'status'); previews.after(composerStatus);}
|
||||||
const input = node('input'); input.type = 'file'; input.multiple = true; input.hidden = true; input.setAttribute('aria-label', 'Choose attachments');
|
const input = node('input'); input.type = 'file'; input.multiple = true; input.hidden = true; input.setAttribute('aria-label', 'Choose attachments');
|
||||||
const attach = button('Attach files', () => input.click()); attach.dataset.attach = ''; input.addEventListener('change', () => {addFiles(input.files); input.value = '';}); toolbar.append(attach, input);
|
const attach = button('Attach files', () => input.click()); attach.dataset.attach = ''; input.addEventListener('change', () => {addFiles(input.files); input.value = '';}); toolbar.append(attach, input);
|
||||||
panel = node('section'); panel.id = 'hopeFeatures'; panel.setAttribute('aria-label', 'Saved work and assistant controls'); ($('libraryContent') || $('chatSidebar') || document.body).append(panel);
|
panel = node('section'); panel.id = 'hopeFeatures'; panel.setAttribute('aria-label', 'Saved work and assistant controls'); ($('libraryContent') || $('chatSidebar') || document.body).append(panel);
|
||||||
status = node('p'); status.id = 'featureStatus'; status.setAttribute('role', 'status'); const availability = node('p'); availability.id = 'featureAvailability'; const availabilityDetails = node('details'); availabilityDetails.append(node('summary', 'Feature availability'), availability); panel.append(status, availabilityDetails);
|
status = node('p'); status.id = 'featureStatus'; status.setAttribute('role', 'status'); const availability = node('p'); availability.id = 'featureAvailability'; const availabilityDetails = node('details', undefined, 'feature-section'); availabilityDetails.append(node('summary', 'Feature availability'), availability); panel.append(status, availabilityDetails);
|
||||||
filesSection.value = section('Files'); artifactSection.value = section('Artifacts'); artifactEditor = node('div'); artifactSection.value.detail.append(artifactEditor);
|
filesSection.value = section('Files'); artifactSection.value = section('Saved drafts'); artifactEditor = node('div', undefined, 'feature-editor'); artifactSection.value.detail.append(artifactEditor);
|
||||||
taskSection.value = section('Scheduled tasks'); taskEditor = node('div'); taskSection.value.detail.append(taskEditor);
|
taskSection.value = section('Scheduled tasks'); taskEditor = node('div', undefined, 'feature-editor'); taskSection.value.detail.append(taskEditor);
|
||||||
memorySection.value = section('Memory'); memoryEditor = node('div'); memorySection.value.detail.append(memoryEditor);
|
memorySection.value = section('Memory'); memoryEditor = node('div', undefined, 'feature-editor'); memorySection.value.detail.append(memoryEditor);
|
||||||
|
$('openLibrary')?.addEventListener('click', () => openSection('files'));
|
||||||
if ($('activityContent')) $('activityContent').append(taskSection.value.detail);
|
if ($('activityContent')) $('activityContent').append(taskSection.value.detail);
|
||||||
for (const [area, load] of [[filesSection.value, loadFiles], [artifactSection.value, loadArtifacts], [taskSection.value, loadTasks], [memorySection.value, loadMemory]]) area.detail.addEventListener('toggle', () => {if (area.detail.open && state.user) load().catch(error => {if (error.name !== 'AbortError') message(error.message);});});
|
for (const [area, load] of [[filesSection.value, loadFiles], [artifactSection.value, loadArtifacts], [taskSection.value, loadTasks], [memorySection.value, loadMemory]]) area.detail.addEventListener('toggle', () => {if (area.detail.open && state.user) load().catch(error => {if (error.name !== 'AbortError') {area.detail.querySelector('.feature-status').textContent = error.message; if (status) status.textContent = error.message;}});});
|
||||||
const drop = options.dropTarget || $('primaryComposer') || toolbar;
|
const drop = options.dropTarget || $('primaryComposer') || toolbar;
|
||||||
drop.addEventListener('dragover', event => {if (event.dataTransfer?.types.includes('Files')) event.preventDefault();});
|
drop.addEventListener('dragover', event => {if (event.dataTransfer?.types.includes('Files')) event.preventDefault();});
|
||||||
drop.addEventListener('drop', event => {if (event.dataTransfer?.files.length) {event.preventDefault(); addFiles(event.dataTransfer.files);}});
|
drop.addEventListener('drop', event => {if (event.dataTransfer?.files.length) {event.preventDefault(); addFiles(event.dataTransfer.files);}});
|
||||||
window.addEventListener('hope:conversation-selected', event => {const next = event.detail?.conversation_id || null; if (state.conversation && next !== state.conversation) clearAll(); state.conversation = next;});
|
window.addEventListener('hope:conversation-selected', event => {state.draftEpoch++; const next = event.detail?.conversation_id || null; if (state.conversation && next !== state.conversation) clearAll(); state.conversation = next;});
|
||||||
HopeAuth.onChange((user, accountChanged) => {
|
HopeAuth.onChange((user, accountChanged) => {
|
||||||
if (accountChanged || !user) {state.epoch++; for (const controller of state.controllers) controller.abort(); state.controllers.clear(); clearAll(); state.capabilities = {}; for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) {area.detail.open = false; area.body.replaceChildren(); area.detail.hidden = true;} artifactEditor.replaceChildren(); taskEditor.replaceChildren(); memoryEditor.replaceChildren(); message('');}
|
if (accountChanged || !user) {state.epoch++; state.draftEpoch++; for (const controller of state.controllers) controller.abort(); state.controllers.clear(); clearAll(); state.capabilities = {}; state.conversation = null; for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) {area.detail.open = false; area.body.replaceChildren(); area.detail.hidden = true; area.unavailable.hidden = true;} artifactEditor.replaceChildren(); taskEditor.replaceChildren(); memoryEditor.replaceChildren(); message('');}
|
||||||
state.user = user; toolbar.hidden = panel.hidden = !user; if (user && (accountChanged || !Object.keys(state.capabilities).length)) refresh().catch(error => {if (error.name !== 'AbortError') message(error.message);});
|
state.user = user; toolbar.hidden = panel.hidden = !user; if (user && (accountChanged || !Object.keys(state.capabilities).length)) refresh().catch(error => {if (error.name !== 'AbortError') message(error.message);});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function mountSection(name, target) {
|
function mountSection(name, target) {
|
||||||
const area = name === 'memory' ? memorySection.value : name === 'tasks' ? taskSection.value : name === 'files' ? filesSection.value : name === 'artifacts' ? artifactSection.value : null;
|
const area = name === 'memory' ? memorySection.value : name === 'tasks' ? taskSection.value : name === 'files' ? filesSection.value : name === 'artifacts' ? artifactSection.value : null;
|
||||||
if (area && target) target.append(area.detail);
|
if (area && target) target.append(area.detail, area.unavailable);
|
||||||
return area?.detail || null;
|
return area?.detail || null;
|
||||||
}
|
}
|
||||||
function openSection(name) {
|
function openSection(name) {
|
||||||
|
|||||||
+3
-3
@@ -77,8 +77,8 @@
|
|||||||
<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></section>
|
</div></div></section>
|
||||||
<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="privacy" hidden><h3>Memory & data</h3><p class="settings-intro">Choose what Hope keeps for future conversations. Saved memory is separate from your chat history and uploaded files.</p><p id="privacyIdentity"></p><p id="settingsMemoryStatus" role="status"></p><div id="privacyMemory"></div><section class="data-guide" aria-labelledby="dataGuideTitle"><h4 id="dataGuideTitle">Manage your other saved data</h4><dl><div><dt>Conversations</dt><dd>Use the ••• menu beside a chat to archive it. To delete a chat, open it and use Conversation actions at the top right. Archiving keeps the chat; deleting removes it.</dd></div><div><dt>Files & saved drafts</dt><dd>Review downloads and delete individual items in Library. Deleting a file also makes it unavailable to old chats that reference it.</dd></div></dl><button id="settingsOpenLibrary" class="btn btn-secondary">Manage files & drafts</button></section></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="security" hidden><h3>Security</h3><p class="settings-intro">Your current connection and account access—not another set of switches to configure.</p><dl class="security-facts"><div><dt>Connected server</dt><dd id="securityServer"></dd></div><div><dt>Connection</dt><dd id="securityConnection"></dd></div><div><dt>Signed-in account</dt><dd id="securityIdentity"></dd></div><div><dt>Session access</dt><dd>Hope uses a server-managed cookie session. Signing out ends this browser’s current session.</dd></div></dl><section class="security-action"><h4>Leaving a shared device?</h4><p>Sign out so the next person cannot open your conversations or files.</p><button id="securitySignOut" data-settings-signout class="btn btn-secondary" type="button">Sign out of this browser</button></section><section class="security-action"><h4>Password or account concerns?</h4><p>Contact your Hope administrator for a password reset. A reset revokes existing sessions. Session lists, self-service password changes and two-factor settings are not available here.</p></section></section>
|
||||||
<section data-settings-panel="admin" hidden> <section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading">
|
<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>
|
<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>
|
<p id="adminMessage" role="status">Loading accounts…</p>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div></div></dialog>
|
</div></div></dialog>
|
||||||
<div id="sheetBackdrop" class="sheet-backdrop" hidden></div>
|
<div id="sheetBackdrop" class="sheet-backdrop" hidden></div>
|
||||||
<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>
|
<dialog id="libraryDialog" class="library-window" aria-labelledby="libraryTitle"><div class="dialog-head"><div><h2 id="libraryTitle">Library</h2><p class="settings-intro">Your reference files and saved drafts.</p></div><button id="closeLibrary" class="btn btn-icon" aria-label="Close Library">×</button></div><p class="library-guide">Upload a file, choose <strong>Use in conversation</strong>, then ask Hope about it. Files are only included when you send a message—not automatically in every chat.</p><div id="libraryContent"></div></dialog>
|
||||||
<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 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 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>
|
<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>
|
||||||
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
|
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
|
||||||
|
|||||||
+11
-1
@@ -19,7 +19,7 @@
|
|||||||
function updateMemoryStatus() {
|
function updateMemoryStatus() {
|
||||||
text('settingsMemoryStatus', !memory || memory.hidden
|
text('settingsMemoryStatus', !memory || memory.hidden
|
||||||
? 'Memory controls are unavailable for this account. No memory has been changed.'
|
? 'Memory controls are unavailable for this account. No memory has been changed.'
|
||||||
: 'Review or edit your saved memory below. Clearing memory does not delete conversations.');
|
: '');
|
||||||
}
|
}
|
||||||
function identity(user, changed) {
|
function identity(user, changed) {
|
||||||
text('settingsAccountName', user?.display_name || user?.username || '');
|
text('settingsAccountName', user?.display_name || user?.username || '');
|
||||||
@@ -51,6 +51,10 @@
|
|||||||
button.classList.toggle('selected', active);
|
button.classList.toggle('selected', active);
|
||||||
if (active) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
|
if (active) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
|
||||||
});
|
});
|
||||||
|
if (window.matchMedia('(max-width: 600px)').matches) {
|
||||||
|
const selected = dialog.querySelector('[data-settings-target].selected');
|
||||||
|
selected?.scrollIntoView({block: 'nearest', inline: 'nearest', behavior: 'instant'});
|
||||||
|
}
|
||||||
// auth.js keeps its polling and provisioning handlers; never copy them.
|
// auth.js keeps its polling and provisioning handlers; never copy them.
|
||||||
if ($('adminSection')) $('adminSection').hidden = section !== 'admin';
|
if ($('adminSection')) $('adminSection').hidden = section !== 'admin';
|
||||||
$('adminBtn')?.setAttribute('aria-expanded', String(section === 'admin'));
|
$('adminBtn')?.setAttribute('aria-expanded', String(section === 'admin'));
|
||||||
@@ -64,6 +68,12 @@
|
|||||||
if (typeof loadModelSettings === 'function') loadModelSettings();
|
if (typeof loadModelSettings === 'function') loadModelSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (section === 'security') {
|
||||||
|
text('securityServer', window.location.host);
|
||||||
|
text('securityConnection', window.location.protocol === 'https:' ? 'HTTPS · connection encrypted in transit' : 'Not HTTPS · use your secure Hope address');
|
||||||
|
const user = window.HopeAuth?.user;
|
||||||
|
text('securityIdentity', user ? `${user.display_name || user.username} (@${user.username})` : 'Not signed in');
|
||||||
|
}
|
||||||
if (section === 'privacy') {
|
if (section === 'privacy') {
|
||||||
if (!memory) memory = window.HopeFeatures?.mountMemory?.($('privacyMemory') || panel('privacy')) || null;
|
if (!memory) memory = window.HopeFeatures?.mountMemory?.($('privacyMemory') || panel('privacy')) || null;
|
||||||
const status = $('featureStatus');
|
const status = $('featureStatus');
|
||||||
|
|||||||
@@ -150,7 +150,10 @@ input::placeholder,textarea::placeholder{
|
|||||||
opacity:1}
|
opacity:1}
|
||||||
.chat-sidebar nav{
|
.chat-sidebar nav{
|
||||||
flex:1;
|
flex:1;
|
||||||
|
min-height:0;
|
||||||
|
overflow-y:auto;
|
||||||
margin-top:18px}
|
margin-top:18px}
|
||||||
|
.sidebar-brand,.chat-sidebar>.new-chat,.chat-sidebar>input,.sidebar-footer{flex-shrink:0}
|
||||||
.chat-sidebar h2{
|
.chat-sidebar h2{
|
||||||
font-size:12px;
|
font-size:12px;
|
||||||
font-weight:600;
|
font-weight:600;
|
||||||
@@ -840,6 +843,11 @@ dialog p{
|
|||||||
border-left:1px solid var(--line);
|
border-left:1px solid var(--line);
|
||||||
padding:22px}
|
padding:22px}
|
||||||
.tasks-header{
|
.tasks-header{
|
||||||
|
position:sticky;
|
||||||
|
top:-22px;
|
||||||
|
background:var(--canvas);
|
||||||
|
z-index:1;
|
||||||
|
padding-top:12px;
|
||||||
display:flex;
|
display:flex;
|
||||||
align-items:flex-start;
|
align-items:flex-start;
|
||||||
justify-content:space-between;
|
justify-content:space-between;
|
||||||
@@ -894,6 +902,84 @@ dialog p{
|
|||||||
#hopeFeatures details{
|
#hopeFeatures details{
|
||||||
padding:14px 0;
|
padding:14px 0;
|
||||||
border-bottom:1px solid var(--line)}
|
border-bottom:1px solid var(--line)}
|
||||||
|
/* Feature sections retain their layout when mounted in Library, Activity or Settings. */
|
||||||
|
.feature-section{min-width:0;margin-block:20px;border-bottom:1px solid var(--line);padding-bottom:20px}
|
||||||
|
.feature-section>summary{font-size:1rem;font-weight:600;cursor:pointer;min-height:44px;align-content:center}
|
||||||
|
.feature-section-body{min-width:0;padding-top:12px}
|
||||||
|
.feature-intro,.feature-empty,.feature-status{line-height:1.6;max-width:68ch;overflow-wrap:anywhere}
|
||||||
|
.feature-intro{color:var(--ink-soft);margin-bottom:16px}
|
||||||
|
.feature-status:empty{display:none}
|
||||||
|
.feature-status{margin-block:12px}
|
||||||
|
.feature-toolbar,.feature-row-actions,.feature-form-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-block:12px}
|
||||||
|
.feature-list{display:flex;flex-direction:column;min-width:0}
|
||||||
|
.feature-row{display:flex;flex-direction:column;gap:12px;align-items:stretch;padding-block:18px;border-top:1px solid var(--line);min-width:0}
|
||||||
|
.feature-row-main{flex:1;min-width:0;overflow-wrap:anywhere}
|
||||||
|
.feature-row-main h3{font-size:1rem;margin-bottom:6px}
|
||||||
|
.feature-row-main p{line-height:1.6;color:var(--ink-soft);margin-block:6px}
|
||||||
|
.feature-row-actions{flex:0 1 auto;margin:0;max-width:100%}
|
||||||
|
.feature-form{display:flex;flex-direction:column;gap:18px;min-width:0;margin-top:16px}
|
||||||
|
.feature-field{display:flex;flex-direction:column;gap:8px;min-width:0;font-weight:500;line-height:1.5}
|
||||||
|
.feature-field input,.feature-field textarea,.feature-field select{display:block;width:100%;min-width:0;max-width:100%;padding:12px;border:1px solid var(--hope-control-border);border-radius:8px;background:var(--canvas);font-weight:400;min-height:44px}
|
||||||
|
.feature-field textarea{resize:vertical;line-height:1.6;min-height:120px}
|
||||||
|
.feature-field small{font-weight:400;color:var(--ink-soft);line-height:1.5}
|
||||||
|
.feature-checkbox{display:grid;grid-template-columns:20px minmax(0,1fr);gap:8px 10px;align-items:start;line-height:1.5}
|
||||||
|
.feature-checkbox>input{grid-column:1;grid-row:1}
|
||||||
|
.feature-checkbox>label{grid-column:2;grid-row:1}
|
||||||
|
.feature-checkbox>.feature-hint{grid-column:2}
|
||||||
|
.feature-checkbox input[type=checkbox]{width:20px!important;min-width:20px;min-height:20px;margin:2px 0;padding:0;flex-shrink:0;accent-color:var(--accent)}
|
||||||
|
.feature-editor{min-width:0;border-top:1px solid var(--line);padding-top:20px;margin-top:16px}
|
||||||
|
.feature-editor:empty{display:none}
|
||||||
|
.feature-editor pre{white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%;line-height:1.6}
|
||||||
|
.feature-empty{padding:20px 0;color:var(--ink-soft)}
|
||||||
|
.feature-row details{width:100%;padding:0!important;margin:0;border-bottom:0!important}
|
||||||
|
.feature-section-body>.feature-intro{margin:0 0 14px!important}
|
||||||
|
.feature-section-body .feature-toolbar{margin:8px 0 12px}
|
||||||
|
.feature-row-main p{margin:4px 0!important}
|
||||||
|
.feature-row-actions{margin:0!important}
|
||||||
|
.feature-row .feature-row-actions details{flex-basis:100%}
|
||||||
|
.feature-row .feature-row-actions summary{font-size:13px;font-weight:400}
|
||||||
|
#hopeFeatures>.feature-section{padding-block:12px;margin-block:12px}
|
||||||
|
#hopeFeatures>.feature-section:first-of-type{margin-block:4px;padding-block:8px;order:3}
|
||||||
|
#hopeFeatures{display:flex;flex-direction:column}
|
||||||
|
#hopeFeatures>.feature-section{order:1}
|
||||||
|
#hopeFeatures>.feature-empty{order:1}
|
||||||
|
.library-guide{margin-block:12px 16px}
|
||||||
|
.feature-section>summary{min-height:40px}
|
||||||
|
#privacyMemory .feature-section{margin-top:12px}
|
||||||
|
#privacyMemory .feature-section-body{padding-top:0}
|
||||||
|
.feature-toolbar .btn:first-child{background:var(--hope-selected-wash);color:var(--accent);font-weight:600}
|
||||||
|
#activityContent .feature-row+.feature-editor{scroll-margin-top:20px}
|
||||||
|
.feature-row summary{min-height:44px;align-content:center;cursor:pointer}
|
||||||
|
.library-window{width:min(820px,calc(100vw - 32px));max-width:820px}
|
||||||
|
#activityContent{min-width:0;flex-shrink:0}
|
||||||
|
.tasks-list{flex-shrink:0}
|
||||||
|
.settings-window>.dialog-head{position:sticky;top:0;background:var(--canvas);z-index:1}
|
||||||
|
.library-window>.dialog-head{position:sticky;top:-24px;background:var(--canvas);z-index:1;padding-top:8px}
|
||||||
|
.library-guide{line-height:1.65;max-width:68ch;margin-block:16px 24px;color:var(--ink-soft)}
|
||||||
|
.settings-intro{line-height:1.65;max-width:65ch;color:var(--ink-soft);margin-top:8px}
|
||||||
|
.feature-hint{font-size:13px;font-weight:400;color:var(--ink-soft);line-height:1.5;margin:0!important}
|
||||||
|
#settingsMemoryStatus:empty{display:none}
|
||||||
|
@media(max-width:600px){.settings-nav{display:flex!important;flex-wrap:nowrap!important;overflow-x:auto;gap:4px}.settings-nav .btn{width:auto!important;flex:0 0 auto;white-space:nowrap;margin:0}.settings-nav .admin-link{border-top:0;margin-top:0;padding-top:10px;min-height:44px}}
|
||||||
|
.data-guide,.security-action{padding-block:24px;border-top:1px solid var(--line);margin-top:24px}
|
||||||
|
.data-guide h4,.security-action h4{font-size:1rem;margin-bottom:10px}
|
||||||
|
.data-guide p,.security-action p{line-height:1.65;max-width:65ch;margin-bottom:16px}
|
||||||
|
.data-guide dl,.security-facts{margin:20px 0;display:flex;flex-direction:column;gap:20px}
|
||||||
|
.data-guide dt,.security-facts dt{font-weight:600;margin-bottom:6px}
|
||||||
|
.data-guide dd,.security-facts dd{margin:0;line-height:1.6;color:var(--ink-soft);overflow-wrap:anywhere}
|
||||||
|
.conversation-row{display:flex;align-items:center;gap:4px;min-width:0}
|
||||||
|
.conversation-row .conversation-link{min-width:0;flex:1}
|
||||||
|
.conversation-row-actions{border:0;background:transparent;border-radius:8px;min-width:44px;min-height:44px;color:var(--ink-soft);flex-shrink:0}
|
||||||
|
.conversation-row-actions:hover,.conversation-row-actions:focus-visible{background:var(--surface-raised);color:var(--ink)}
|
||||||
|
.conversation-row-menu{position:fixed;margin:0;min-width:180px;max-width:calc(100vw - 24px);padding:8px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink)}
|
||||||
|
.conversation-row-menu .btn{display:block;width:100%;text-align:left;min-height:44px}
|
||||||
|
@media(max-width:600px){
|
||||||
|
.feature-row{flex-direction:column;gap:10px}
|
||||||
|
.feature-row-actions{width:100%}
|
||||||
|
.feature-row-actions .btn{max-width:100%;white-space:normal;overflow-wrap:anywhere}
|
||||||
|
.feature-form-actions .btn{flex:1 1 auto}
|
||||||
|
.library-window{width:calc(100vw - 20px);padding:18px}
|
||||||
|
}
|
||||||
|
|
||||||
#hopeFeatures summary{
|
#hopeFeatures summary{
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
min-height:44px;
|
min-height:44px;
|
||||||
|
|||||||
Reference in new issue
Block a user