From 8dda23a1d85dc60271cc2d4dcddbf76752f14d3a Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sat, 3 Oct 2026 08:50:08 -0400 Subject: [PATCH] feat: add retained upload management controls --- tests/unit/test_chat_features_web.py | 12 +++++++++++- web/features.js | 15 ++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_chat_features_web.py b/tests/unit/test_chat_features_web.py index f9fbc5a..cdb8573 100644 --- a/tests/unit/test_chat_features_web.py +++ b/tests/unit/test_chat_features_web.py @@ -16,10 +16,11 @@ MOCK = r'''() => { binary:options.body instanceof File,headers:options.headers}); if (path==='/api/capabilities') return {features:capabilities}; if (path==='/api/attachments') { + if(options.method==='GET') return {attachments:[{id:'c'.repeat(32),filename:'notes.txt',size:5,extraction_status:'ready'}]}; 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(path==='/api/artifacts') return options.method==='POST'?{artifact:{...artifact,...JSON.parse(options.body),revision:1}}:{artifacts:[artifact]}; + 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(options.method==='PATCH') {if(conflict) throw new Error('Artifact revision conflict');artifact={...artifact,...JSON.parse(options.body),revision:artifact.revision+1};} return {artifact}; @@ -79,6 +80,15 @@ class ChatFeaturesWebTests(unittest.TestCase): self.page.evaluate('HopeFeatures.clearAttachmentsAfterSend()') self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), []) + def test_retained_upload_management_delete_is_explicit(self): + self.open_panel('Files') + self.page.get_by_text('Delete notes.txt',exact=True).click() + 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.assertTrue(self.page.get_by_role('link',name='Download notes.txt').get_attribute('href').endswith('/content')) + self.page.get_by_role('button',name='Confirm delete notes.txt').click() + self.page.wait_for_function('calls.some(c=>c.path.startsWith("/api/attachments/")&&c.method==="DELETE")') + def test_cancel_retry_and_conversation_change(self): self.page.evaluate('pendingUpload=true') self.page.locator('input[type=file]').set_input_files({'name':'notes.txt','mimeType':'text/plain','buffer':b'Hello'}) diff --git a/web/features.js b/web/features.js index 79078a8..b87a405 100644 --- a/web/features.js +++ b/web/features.js @@ -69,7 +69,12 @@ function submitButton(form, label) {const el = node('button', label, 'btn btn-primary'); el.type = 'submit'; form.append(el); return el;} 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);} function download(path, label) {const link = node('a', label, 'btn btn-ghost'); link.href = path; link.setAttribute('download', ''); return link;} - const 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() { + const data = await request('/api/attachments'), body = filesSection.value.body; body.replaceChildren(button('Refresh files', loadFiles)); + if (!data.attachments?.length) body.append(node('p', 'Uploaded files stay in your private 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);} + } 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)); if (!data.artifacts?.length) body.append(node('p', 'Save a draft, note or code snippet to keep versions across devices.')); @@ -115,7 +120,7 @@ const result = await request('/api/capabilities'); 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.querySelector('[data-attach]').disabled = input.disabled; - 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; 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(' '); } @@ -129,16 +134,16 @@ 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'); ($('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); - artifactSection.value = section('Artifacts'); artifactEditor = node('div'); artifactSection.value.detail.append(artifactEditor); + filesSection.value = section('Files'); artifactSection.value = section('Artifacts'); artifactEditor = node('div'); artifactSection.value.detail.append(artifactEditor); taskSection.value = section('Scheduled tasks'); taskEditor = node('div'); taskSection.value.detail.append(taskEditor); memorySection.value = section('Memory'); memoryEditor = node('div'); memorySection.value.detail.append(memoryEditor); - for (const [area, load] of [[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') message(error.message);});}); const drop = options.dropTarget || $('primaryComposer') || toolbar; 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);}}); window.addEventListener('hope:conversation-selected', event => {const next = event.detail?.conversation_id || null; if (state.conversation && next !== state.conversation) clearAll(); state.conversation = next;}); 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 [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++; 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('');} 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);}); }); }