// Account-scoped advanced chat controls. Server capability flags are authoritative. (() => { 'use strict'; 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); 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;}; 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;} async function request(path, method = 'GET', body, extra = {}) { const epoch = state.epoch, controller = new AbortController(); state.controllers.add(controller); try { const result = await (options.request || HopeAuth.request)(path, {method, ...(body === undefined ? {} : {body: JSON.stringify(body)}), ...extra, signal: extra.signal || controller.signal}); if (epoch !== state.epoch) throw new DOMException('Account changed', 'AbortError'); return result; } finally {state.controllers.delete(controller);} } 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 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 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 mime(file) {return file.type || ({txt: 'text/plain', md: 'text/markdown', pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp'}[file.name.split('.').pop().toLowerCase()] || '');} async function upload(item) { const epoch = state.epoch; item.status = 'uploading'; item.error = ''; item.controller = new AbortController(); changed(); try { const data = await request('/api/attachments', 'POST', undefined, {body: item.file, headers: {'Content-Type': mime(item.file), 'X-Filename': item.file.name}, signal: item.controller.signal}); if (epoch !== state.epoch || !state.attachments.includes(item)) return; item.attachment = data.attachment; const extraction = data.attachment.extraction_status; if (['failed', 'unavailable', 'encrypted'].includes(extraction)) {item.status = 'blocked'; item.error = 'Text could not be extracted. Remove this file or use a UTF-8 text file.';} else item.status = 'ready'; } catch (error) {if (epoch !== state.epoch || !state.attachments.includes(item)) return; item.status = 'failed'; item.error = error.name === 'AbortError' ? 'Upload cancelled. Retry or remove the file.' : error.message;} finally {if (epoch === state.epoch) changed();} } async function addFiles(files) { 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)) { const maximum = state.capabilities.attachments_per_message || 8; if (state.attachments.length >= maximum) {errors.push(`Attach at most ${maximum} files per message.`); break;} 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) {errors.push('Image input is not available for the selected model.'); 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)) {errors.push('File name is not supported. Rename the file and retry.'); continue;} const item = {file, status: 'uploading', attachment: null, error: ''}; if (mime(file).startsWith('image/')) {item.url = URL.createObjectURL(file); state.urls.add(item.url);} state.attachments.push(item); accepted.push(file.name); upload(item); } 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() { if (!previews) return; previews.replaceChildren(); for (const item of state.attachments) { 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);} row.append(node('span', `${item.file.name} · ${Math.ceil(item.file.size / 1024)} KB · ${item.status}`)); 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.status === 'uploading') row.append(button('Cancel upload', () => item.controller.abort())); if (item.status === 'failed') row.append(button('Retry upload', () => upload(item))); row.append(button('Remove ' + item.file.name, () => {dispose(item); state.attachments = state.attachments.filter(candidate => candidate !== item); changed();})); previews.append(row); } } function field(form, label, kind = 'input', value = '', hint = '') { const wrap = node('div', undefined, 'feature-field'), input = node(kind), id = 'feature-field-' + ++fieldSequence; input.id = id; input.value = value; const caption = node('label', label); caption.htmlFor = id; wrap.append(caption, input); if (hint) {const help = node('p', hint, 'feature-hint'); help.id = id + '-hint'; input.setAttribute('aria-describedby', help.id); wrap.append(help);} form.append(wrap); return input; } 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}; async function loadFiles() { const data = await request('/api/attachments'); 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, 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() { const data = await request('/api/artifacts'); 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, 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) { 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 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 preview = node('pre', artifact?.content || ''); content.addEventListener('input', () => {preview.textContent = content.value;}); submitButton(form, artifact ? 'Save new version' : 'Create draft'); form.append(button('Close editor', () => artifactEditor.replaceChildren())); 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 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); } formSubmit(form, async () => {const data = {title: title.value.trim(), kind: kind.value, content: content.value}; if (new TextEncoder().encode(data.content).length > (state.capabilities.artifact_bytes || 512 * 1024)) throw new Error('Artifact exceeds the content limit.'); const saved = await request('/api/artifacts' + (artifact ? '/' + enc(artifact.id) : ''), artifact ? 'PATCH' : 'POST', {...data, ...(artifact ? {revision: artifact.revision} : {})}); message('Artifact saved as version ' + saved.artifact.revision + '.'); await loadArtifacts(); editArtifact(saved.artifact);}); artifactEditor.append(heading, form, node('h4', 'Safe text preview'), preview); title.focus(); } async function loadTasks() { const data = await request('/api/tasks'); 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, 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) { 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; 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'); 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(); } async function loadMemory() { 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 || []) { 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.');}); 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); } } async function refresh() { if (!state.user) return; 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.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; for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) area.unavailable.hidden = !area.detail.hidden; 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 = {}) { options = {...options, ...config}; if (state.initialized) return; state.initialized = true; toolbar = options.toolbar || $('featureActions'); previews = options.previews || $('attachmentPreviews'); if (!toolbar) {toolbar = node('div'); toolbar.id = 'featureActions'; ($('primaryComposer') || document.body).append(toolbar);} if (!previews) {previews = node('div'); previews.id = 'attachmentPreviews'; toolbar.after(previews);} 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 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); 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('Saved drafts'); artifactEditor = node('div', undefined, 'feature-editor'); artifactSection.value.detail.append(artifactEditor); taskSection.value = section('Scheduled tasks'); taskEditor = node('div', undefined, 'feature-editor'); taskSection.value.detail.append(taskEditor); 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); 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; 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 => {state.draftEpoch++; 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++; 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);}); }); } function mountSection(name, target) { 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, area.unavailable); return area?.detail || null; } function openSection(name) { const area = name === 'memory' ? memorySection.value : name === 'tasks' ? taskSection.value : name === 'files' ? filesSection.value : name === 'artifacts' ? artifactSection.value : null; if (area) area.detail.open = true; } window.HopeFeatures = {initialize, attachmentIDs, hasPendingUploads, clearAttachmentsAfterSend, clearAfterSend: clearAttachmentsAfterSend, addFiles, refresh, mountMemory: target => mountSection('memory', target), mountTasks: target => mountSection('tasks', target), openSection}; initialize(); })();