Merge verified Odysseus fixes

This commit is contained in:
pewdiepie-archdaemon
2026-07-23 14:49:02 +00:00
parent 4c9a8ca115
commit d8a2059df8
117 changed files with 15693 additions and 3191 deletions
+274 -36
View File
@@ -20,6 +20,8 @@ let _escHandler = null;
let _viewingRuns = null; // task id when viewing run history
let _clockInterval = null;
let _taskFailurePending = false;
let _taskCompletionPending = false;
let _taskBulkDeleting = false;
const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
@@ -29,6 +31,12 @@ function _setTaskFailurePending(active) {
document.getElementById('rail-tasks')?.classList.toggle('task-failure-pending', _taskFailurePending);
}
function _setTaskCompletionPending(active) {
_taskCompletionPending = !!active;
document.getElementById('tool-tasks-btn')?.classList.toggle('task-completion-pending', _taskCompletionPending);
document.getElementById('rail-tasks')?.classList.toggle('task-completion-pending', _taskCompletionPending);
}
// ---- API ----
async function _fetchTasks() {
@@ -105,6 +113,25 @@ function _animateTaskRemoval(ids) {
return new Promise(resolve => setTimeout(resolve, 520));
}
function _setTaskCardsDeleting(ids, active) {
for (const id of ids) {
const card = _taskCardById(id);
if (!card) continue;
card.classList.toggle('task-card-deleting', !!active);
const existing = card.querySelector('.task-card-delete-busy');
if (!active) {
existing?.remove();
continue;
}
if (!existing) {
const badge = document.createElement('span');
badge.className = 'task-card-delete-busy';
badge.innerHTML = '<span class="task-card-delete-busy-label">Deleting</span><span class="task-card-delete-busy-spin" aria-hidden="true"></span>';
card.appendChild(badge);
}
}
}
async function _pauseTask(id) {
const res = await fetch(`${API_BASE}/api/tasks/${id}/pause`, {
method: 'POST', credentials: 'same-origin',
@@ -669,17 +696,65 @@ function _taskUpdateBulkCount() {
}
async function _taskBulkDelete() {
const ids = [..._taskSelected];
if (!ids.length) return;
if (!ids.length || _taskBulkDeleting) return;
const ok = uiModule?.styledConfirm
? await uiModule.styledConfirm(`Delete ${ids.length} task${ids.length > 1 ? 's' : ''}? This cannot be undone.`, { confirmText: 'Delete', danger: true })
: confirm(`Delete ${ids.length} task(s)?`);
if (!ok) return;
const results = await Promise.allSettled(ids.map(id => _deleteTask(id)));
const deletedIds = ids.filter((_, i) => results[i].status === 'fulfilled');
await _animateTaskRemoval(deletedIds);
if (uiModule) uiModule.showToast(`Deleted ${deletedIds.length} task${deletedIds.length > 1 ? 's' : ''}`);
await _fetchTasks();
_taskExitSelect(); // clears selection + re-renders the fresh list
_taskBulkDeleting = true;
const countEl = document.getElementById('tasks-selected-count');
const deleteBtn = document.getElementById('tasks-bulk-delete');
const cancelBtn = document.getElementById('tasks-bulk-cancel');
const selectAll = document.getElementById('tasks-select-all');
const originalDeleteHtml = deleteBtn?.innerHTML || '';
let busySpinner = null;
if (deleteBtn) {
deleteBtn.disabled = true;
deleteBtn.classList.add('tasks-bulk-loading');
deleteBtn.innerHTML = '<span class="tasks-bulk-loading-label">Deleting</span>';
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
const spEl = busySpinner.createElement();
spEl.classList.add('tasks-bulk-whirlpool');
deleteBtn.appendChild(spEl);
busySpinner.start();
}
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
if (countEl) countEl.textContent = `Deleting 0/${ids.length}`;
_setTaskCardsDeleting(ids, true);
const deletedIds = [];
let finished = 0;
try {
const results = await Promise.allSettled(ids.map(async (id) => {
try {
await _deleteTask(id);
deletedIds.push(id);
} finally {
finished += 1;
if (countEl) countEl.textContent = `Deleting ${finished}/${ids.length}`;
}
}));
const failed = results.filter(r => r.status === 'rejected').length;
await _animateTaskRemoval(deletedIds);
if (uiModule) {
const msg = failed
? `Deleted ${deletedIds.length}, failed ${failed}`
: `Deleted ${deletedIds.length} task${deletedIds.length > 1 ? 's' : ''}`;
uiModule.showToast(msg);
}
await _fetchTasks();
} finally {
_setTaskCardsDeleting(ids, false);
if (busySpinner) busySpinner.destroy();
if (deleteBtn) {
deleteBtn.classList.remove('tasks-bulk-loading');
deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML;
}
if (cancelBtn) cancelBtn.disabled = false;
if (selectAll) selectAll.disabled = false;
_taskBulkDeleting = false;
_taskExitSelect(); // clears selection + re-renders the fresh list
}
}
// Category filter chips (library-style tags) — solo-select: click one to
@@ -1937,10 +2012,93 @@ function _switchTab(tab) {
b.classList.toggle('active', on);
});
if (tab === 'tasks') _renderMainView();
else if (tab === 'completed') _renderCompletedView();
else if (tab === 'activity') _renderActivityView();
else if (tab === 'new') _showPresetPicker();
}
function _runToActivityEntry(r) {
let resultText = r.result || r.error || '';
if (!resultText) {
if (r.status === 'queued') resultText = '_Queued — waiting for a free slot…_';
if (r.status === 'running') resultText = '_Running…_';
}
return {
kind: r.task_type || 'llm',
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
taskId: r.task_id,
action: r.action || '',
result: resultText,
prompt: '',
ts: r.finished_at || r.started_at,
status: r.status,
model: r.model || '',
endpointUrl: r.endpoint_url || '',
sessionId: r.session_id || '',
researchId: r.research_id || '',
output_target: r.output_target || 'session',
};
}
function _isFinishedRun(entry) {
return !['queued', 'running', 'skipped'].includes(entry.status || '');
}
function _isChatResultRun(entry) {
return _isFinishedRun(entry)
&& (entry.kind === 'llm' || entry.kind === 'research')
&& !!(entry.result || '').trim();
}
async function _renderCompletedView() {
_setTaskCompletionPending(false);
const modal = document.getElementById('tasks-modal');
const body = modal?.querySelector('.modal-body');
if (!body) return;
body.innerHTML = `
<div class="admin-card tasks-activity-card tasks-completed-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:0;">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
<h2 style="margin:0;padding:0;line-height:1;">Completed</h2>
<button class="memory-toolbar-btn" id="tasks-completed-refresh" title="Refresh" style="margin-left:auto;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
</div>
<p class="memory-desc">Completed assistant/research outputs you can open in chat.</p>
<div id="tasks-completed-list" class="memory-list tasks-activity-list tasks-completed-list" style="flex:1;overflow:auto;font-size:13px;min-height:0;"></div>
</div>
`;
document.getElementById('tasks-completed-refresh')?.addEventListener('click', _renderCompletedView);
const list = document.getElementById('tasks-completed-list');
list?.appendChild(spinnerModule.createLoadingRow('Loading…'));
try {
const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=${_completedLimit}&max_result_chars=10000`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const finished = (data.runs || []).map(_runToActivityEntry).filter(_isChatResultRun);
_completedHasMore = !!data.has_more && _completedLimit < 200;
_activityEntries = finished;
_syncCompletedTabCount(finished.length);
if (!list) return;
if (finished.length === 0) {
list.innerHTML = '<div class="doclib-empty task-completed-empty">No completed task outputs yet.</div>';
return;
}
list.innerHTML = finished.map(_renderCompletedPreviewEntry).join('');
if (_completedHasMore) {
list.insertAdjacentHTML('beforeend', `
<button type="button" class="memory-toolbar-btn tasks-activity-load-more" id="tasks-completed-load-more" style="width:100%;justify-content:center;margin-top:6px;">
Load more
</button>
`);
list.querySelector('#tasks-completed-load-more')?.addEventListener('click', () => {
_completedLimit = Math.min(200, _completedLimit + 40);
_renderCompletedView();
});
}
_wireCompletedPreviewRows(list);
} catch (e) {
if (list) list.innerHTML = `<div style="opacity:0.5;padding:12px;">Failed to load completed tasks: ${_escHtml(e.message || String(e))}</div>`;
}
}
// ---- Activity view (assistant session log) ----
async function _renderActivityView() {
@@ -2082,32 +2240,8 @@ async function _renderActivityView() {
list.innerHTML = '<div style="opacity:0.5;padding:12px;">No activity yet. Scheduled tasks will log here once they run.</div>';
return;
}
_activityEntries = runs.map(r => {
let resultText = r.result || r.error || '';
if (!resultText) {
if (r.status === 'queued') resultText = '_Queued — waiting for a free slot…_';
if (r.status === 'running') resultText = '_Running…_';
}
return {
// Surface the actual task_type ('llm' | 'research' | 'action') so the
// chat-worthy check in _renderActivityEntry can decide between "Open
// in chat" (llm/research) and "Copy log" (action). Was hardcoded
// 'task', which never matched and made Open-in-chat dead code.
kind: r.task_type || 'llm',
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
taskId: r.task_id,
action: r.action || '',
result: resultText,
prompt: '',
ts: r.finished_at || r.started_at,
status: r.status,
model: r.model || '',
endpointUrl: r.endpoint_url || '',
sessionId: r.session_id || '',
researchId: r.research_id || '',
output_target: r.output_target || 'session',
};
});
_activityEntries = runs.map(_runToActivityEntry);
_syncCompletedTabCount(_activityEntries.filter(_isChatResultRun).length);
_buildChips();
_applyFilter();
} catch (e) {
@@ -2119,6 +2253,13 @@ async function _renderActivityView() {
let _activityEntries = [];
let _activityLimit = 40;
let _activityHasMore = false;
let _completedLimit = 40;
let _completedHasMore = false;
function _syncCompletedTabCount(count) {
const el = document.getElementById('tasks-completed-tab-count');
if (el) el.textContent = String(count || 0);
}
function _stackActivityEntries(entries) {
const out = [];
@@ -2291,6 +2432,86 @@ function _wireActivityRows(list) {
});
}
function _renderCompletedPreviewEntry(entry) {
const entryIdx = _activityEntries.indexOf(entry);
const tsLabel = _relativeTime(entry.ts);
const tsAbs = entry.ts ? new Date(entry.ts).toLocaleString() : '';
const modelTag = entry.model
? `<span class="doclib-chat-msg-model">${_escHtml(entry.model.split('/').pop())}</span>`
: '';
const raw = (entry.result || '').trim();
const truncated = raw.length > 1400 ? raw.slice(0, 1400) + '…' : raw;
const cleaned = truncated
.replace(/<think>[\s\S]*?<\/think>/g, '')
.replace(/<think>[\s\S]*$/, '')
.trim();
let body;
try {
body = markdownModule.mdToHtml(cleaned);
} catch {
body = _escHtml(cleaned);
}
const title = _escHtml(entry.taskName || 'Task');
const time = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
return `
<div class="memory-item doclib-chat-row task-completed-preview-row" data-entry-idx="${entryIdx}">
<div class="doclib-chat-header task-completed-preview-head">
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
<span class="task-log-name">${title}</span>${_taskAiMark(entry)}
<span style="flex:1"></span>
${time}
</div>
<div class="doclib-chat-preview task-completed-chat-preview" style="display:block;">
<div class="doclib-chat-preview-messages">
<div class="doclib-chat-bubble-row assistant">
<div class="doclib-chat-bubble">
${modelTag}
<div class="doclib-chat-bubble-body">${body}</div>
</div>
</div>
</div>
<div class="doclib-chat-preview-actions">
<button class="doclib-chat-copy-btn task-completed-copy-btn" type="button">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy
</button>
<button class="doclib-chat-open-btn task-completed-open-chat" type="button">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
Open chat
</button>
</div>
</div>
</div>
`;
}
function _wireCompletedPreviewRows(list) {
list.querySelectorAll('.task-completed-open-chat').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const row = btn.closest('.task-completed-preview-row');
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
const entry = _activityEntries[idx];
if (entry) _openResultInChat(entry);
});
});
list.querySelectorAll('.task-completed-copy-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const row = btn.closest('.task-completed-preview-row');
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
const entry = _activityEntries[idx];
if (!entry) return;
try {
uiModule.copyToClipboard((entry.result || '').trim());
uiModule.showToast('Output copied');
} catch (_) {
uiModule.showError('Copy failed');
}
});
});
}
// Open a task run's result in a fresh chat session so it's comfortable
// to read full-width and the user can ask follow-ups.
async function _openResultInChat(entry) {
@@ -2422,7 +2643,7 @@ function _categoryLabel(taskName) {
return 'other';
}
function _renderActivityEntry(entry) {
function _renderActivityEntry(entry, opts = {}) {
// Canonical index into _activityEntries (map() passes the FILTERED
// index, which would be wrong) — used by the Open-in-chat handler.
const entryIdx = Number.isInteger(entry.sourceIdx) ? entry.sourceIdx : _activityEntries.indexOf(entry);
@@ -2568,7 +2789,7 @@ function _renderActivityEntry(entry) {
`;
}
return `
<div class="task-log-row${rowStatusClass}${long ? ' is-long' : ''}${_isRunning ? ' is-running' : ''}" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
<div class="task-log-row${rowStatusClass}${long ? ' is-long' : ''}${_isRunning ? ' is-running' : ''}${opts.expanded ? ' expanded' : ''}" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
<div class="task-log-row-head">
${statusDot}
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
@@ -2715,10 +2936,13 @@ export function openTasks(focusId, opts) {
startNotificationPolling();
const o = opts || {};
const openActivityForFailure = _taskFailurePending && !focusId && o.filter === undefined;
const openCompletedForNotification = _taskCompletionPending && !focusId && o.filter === undefined;
_setTaskFailurePending(false);
_setTaskCompletionPending(false);
if (_open) {
// Already open — just focus the requested task / apply filter.
if (openActivityForFailure) _switchTab('activity');
else if (openCompletedForNotification) _switchTab('completed');
if (o.filter !== undefined) { _taskFilter = o.filter; _renderList(); }
if (focusId) _focusTask(focusId);
return;
@@ -2751,6 +2975,10 @@ export function openTasks(focusId, opts) {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Activity
</button>
<button class="memory-tab tasks-tab" data-tab="completed" role="tab" aria-selected="false">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><path d="M20 6 9 17l-5-5"/></svg>
Completed <span id="tasks-completed-tab-count" class="memory-count" style="font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px">0</span>
</button>
<button class="memory-tab tasks-tab" data-tab="new" role="tab" aria-selected="false">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add
@@ -2825,7 +3053,7 @@ export function openTasks(focusId, opts) {
// of an empty modal-body that fills in after the fetch resolves — that delay
// was visible as a "flicker" right after opening.
_activeTab = 'tasks';
_switchTab(openActivityForFailure ? 'activity' : 'tasks');
_switchTab(openActivityForFailure ? 'activity' : openCompletedForNotification ? 'completed' : 'tasks');
_fetchTasks().then(() => {
// Re-render so the list swaps the Loading row for real cards.
_renderList();
@@ -2901,6 +3129,15 @@ async function _pollTaskNotifications() {
const notes = data.notifications || [];
for (const n of notes) {
const ok = n.status === 'success';
if (ok) {
const completedOpen = _open && document.querySelector('.tasks-tab.active[data-tab="completed"]');
if (completedOpen) {
_setTaskCompletionPending(false);
_renderCompletedView();
} else {
_setTaskCompletionPending(true);
}
}
// Tasks with output_target='notification' carry the result text in `body`
// — show it as a real browser Notification (richer than a toast). Falls
// back to a toast when permission is denied or unavailable.
@@ -2934,6 +3171,7 @@ async function _pollTaskNotifications() {
function startNotificationPolling() {
if (_notifInterval) return;
setTimeout(_pollTaskNotifications, 1500);
_notifInterval = setInterval(_pollTaskNotifications, 30000);
}