feat: add retained upload management controls

This commit is contained in:
snowspeeder committed 2026-10-03 08:50:08 -04:00
1 parent be2b9f83d0
commit 8dda23a1d8
2 files changed
+21 -6

No files matched your search

+11 -1
View File
@@ -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'})
+10 -5
View File
@@ -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);});
});
}