Polish email tasks and window controls

This commit is contained in:
pewdiepie-archdaemon
2026-06-01 20:56:11 +09:00
parent 5c390d6b3e
commit 5ed9b74cd0
14 changed files with 919 additions and 203 deletions

View File

@@ -23,7 +23,7 @@ const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'S
async function _fetchTasks() {
try {
const res = await fetch(`${API_BASE}/api/tasks?include_last_run=true`, { credentials: 'same-origin' });
const res = await fetch(`${API_BASE}/api/tasks`, { credentials: 'same-origin' });
const data = await res.json();
_tasks = data.tasks || [];
} catch (e) {
@@ -127,6 +127,21 @@ async function _runNow(id, force = false) {
}
}
async function _stopTask(id) {
const res = await fetch(`${API_BASE}/api/tasks/${id}/stop`, {
method: 'POST',
credentials: 'same-origin',
});
if (!res.ok) {
let msg = `Failed to stop task (${res.status})`;
try {
const data = await res.json();
if (data && data.detail) msg = data.detail;
} catch (_) {}
throw new Error(msg);
}
}
async function _fetchRuns(taskId, limit = 10) {
const res = await fetch(`${API_BASE}/api/tasks/${taskId}/runs?limit=${limit}`, {
credentials: 'same-origin',
@@ -568,6 +583,19 @@ function _renderTaskChips() {
for (const c of cats) mkChip(`${c} (${counts[c]})`, c, _taskFilter === c);
}
const _TASK_CACHE_LABELS = {
summarize_emails: 'email summaries',
draft_email_replies: 'AI reply drafts',
extract_email_events: 'email calendar cache',
mark_email_boundaries: 'email boundaries',
learn_sender_signatures: 'sender signatures',
check_email_urgency: 'email tags',
};
function _taskClearCacheLabel(taskOrEntry) {
return _TASK_CACHE_LABELS[taskOrEntry?.action || ''] || '';
}
function _renderList() {
const list = document.getElementById('tasks-list');
if (!list) return;
@@ -630,7 +658,7 @@ function _renderList() {
const statusBadge = task.status === 'paused'
? `<span class="task-status-badge task-paused-badge" data-task-status-action="resume" title="Click to resume" style="position:relative;top:4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg> paused</span>`
: task.status === 'active'
? `<span class="task-status-badge task-active-badge" data-task-status-action="pause" title="Click to pause" style="position:relative;top:4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><polygon points="7 4 19 12 7 20 7 4"/></svg> active</span>`
? `<span class="task-status-badge task-active-badge" data-task-status-action="pause" title="Click to pause" style="position:relative;top:4px;">active</span>`
: '';
const builtinBadge = task.is_builtin
? `<span class="task-builtin-badge${task.is_modified ? ' modified' : ''}" title="${task.is_modified ? 'Built-in task — edited from its default' : 'Built-in task'}">built-in${task.is_modified ? ' · edited' : ''}</span>`
@@ -659,6 +687,9 @@ function _renderList() {
if (task.is_builtin && task.is_modified) {
items.push({ label: 'Revert to default', icon: '<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>', action: () => _doRevert(task.id) });
}
if (_taskClearCacheLabel(task)) {
items.push({ label: 'Clear cache', icon: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/>', action: () => _doClearTaskCache(task.id, _taskClearCacheLabel(task)) });
}
items.push({ label: 'Delete', icon: '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>', action: () => _doDelete(task.id), danger: true });
_showTaskDropdown(menuBtn, items);
});
@@ -667,10 +698,10 @@ function _renderList() {
// manual triggering. Hidden for completed tasks (same gate as before).
if (task.status !== 'completed') {
const runBtn = document.createElement('button');
runBtn.className = 'memory-item-btn task-card-run-btn';
runBtn.className = 'task-status-badge task-run-now-badge task-card-run-btn';
runBtn.title = 'Run now';
runBtn.style.cssText = 'position:relative;top:4px;margin-right:4px;display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 6px;';
runBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Run</span>';
runBtn.style.cssText = 'position:relative;top:1px;margin-right:4px;';
runBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Run now</span>';
runBtn.addEventListener('click', (e) => { e.stopPropagation(); _doRunNow(task.id); });
actionsWrap.insertBefore(runBtn, menuBtn);
}
@@ -1578,6 +1609,25 @@ async function _doRevert(id) {
} catch (e) { if (uiModule) uiModule.showError(e.message); }
}
async function _doClearTaskCache(id, label = 'cache') {
const ok = uiModule?.styledConfirm
? await uiModule.styledConfirm(`Clear cached ${label} for this task?`, { confirmText: 'Clear' })
: confirm(`Clear cached ${label} for this task?`);
if (!ok) return;
try {
const res = await fetch(`${API_BASE}/api/tasks/${encodeURIComponent(id)}/clear-cache`, {
method: 'POST',
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
const n = Object.values(data.cleared || {}).reduce((a, b) => a + Number(b || 0), 0) + Number(data.files || 0);
if (uiModule) uiModule.showToast(`Cleared ${label}${n ? ` (${n})` : ''}`);
} catch (e) {
if (uiModule) uiModule.showError(`Clear cache failed: ${e.message || e}`);
}
}
async function _doToggleAll() {
// If any task is active → pause all. Else resume all paused tasks.
const hasActive = _tasks.some(t => t.status === 'active');
@@ -1680,10 +1730,6 @@ async function _renderActivityView() {
document.getElementById('tasks-activity-refresh').addEventListener('click', _renderActivityView);
// Loading placeholder matches the document library: app whirlpool + label.
const _actList = document.getElementById('tasks-activity-list');
if (_actList) _actList.appendChild(spinnerModule.createLoadingRow('Loading…'));
// Solo filter: clicking a chip shows ONLY that group (a category, or
// Errors). Clicking the active chip again clears the filter (show all).
// At most one chip is active at a time. _solo holds the active key, or null.
@@ -1771,6 +1817,14 @@ async function _renderActivityView() {
const searchEl = document.getElementById('tasks-activity-search');
if (searchEl) searchEl.addEventListener('input', () => { _afQuery = searchEl.value; _buildChips(); _applyFilter(); });
const _actList = document.getElementById('tasks-activity-list');
if (_activityEntries.length) {
_buildChips();
_applyFilter();
} else if (_actList) {
_actList.appendChild(spinnerModule.createLoadingRow('Loading…'));
}
try {
const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=100`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -1796,6 +1850,7 @@ async function _renderActivityView() {
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,
@@ -1916,9 +1971,9 @@ function _wireActivityRows(list) {
// counter). No-op when there's nothing to tick.
_startActivityTimers(list);
list.querySelectorAll('.task-log-row').forEach(row => {
// Click anywhere on the (non-running, non-skipped) row to toggle expand.
// Click anywhere on the row to toggle expand.
// Buttons inside still get their own handlers via stopPropagation.
if (!row.classList.contains('is-running') && !row.classList.contains('is-skipped')) {
if (!row.classList.contains('is-skipped')) {
row.addEventListener('click', () => row.classList.toggle('expanded'));
}
row.querySelector('.task-log-row-toggle')?.addEventListener('click', (e) => {
@@ -1943,6 +1998,25 @@ function _wireActivityRows(list) {
const entry = _activityEntries[idx];
if (entry?.taskId) _doRunNow(entry.taskId, true);
});
row.querySelector('.task-log-stop')?.addEventListener('click', async (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (!entry?.taskId) return;
try {
await _stopTask(entry.taskId);
uiModule.showToast('Task stopped');
_renderActivityView();
} catch (err) {
uiModule.showError(err.message || 'Failed to stop task');
}
});
row.querySelector('.task-log-run-again')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (entry?.taskId) _doRunNow(entry.taskId);
});
row.querySelector('.task-log-copy')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
@@ -1954,6 +2028,12 @@ function _wireActivityRows(list) {
uiModule.showToast('Log copied');
} catch (_) { uiModule.showError('Copy failed'); }
});
row.querySelector('.task-log-clear-cache')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (entry?.taskId) _doClearTaskCache(entry.taskId, _taskClearCacheLabel(entry));
});
});
}
@@ -2113,13 +2193,11 @@ function _renderActivityEntry(entry) {
const statusDot = `<span class="task-log-status task-log-status-${status}" title="${status}"></span>`;
// Render the result through markdown so code blocks, lists, links look right.
let resultHtml;
// Running / queued rows: body stays empty — the status now lives on the
// right side of the head row ("Running <whirlpool>"), wired below.
const _isRunning = entry.status === 'running' || entry.status === 'queued';
// Skipped (noop) rows: render as a slim, dimmed one-liner — no body, no
// actions, just `· name · skipped — reason · time`. CSS via .is-skipped.
const _isSkipped = entry.status === 'skipped';
if (_isRunning) {
if (_isRunning && !(entry.result || '').trim()) {
resultHtml = '';
} else {
try {
@@ -2155,6 +2233,7 @@ function _renderActivityEntry(entry) {
// CSS vars feed the colored title + accent stripe.
const styleVars = `--cat-hue:${hue};`;
const hasResult = !!(entry.result && entry.result.trim() && entry.status !== 'running' && entry.status !== 'queued');
const hasRunningProgress = !!(entry.result && entry.result.trim() && (entry.status === 'running' || entry.status === 'queued'));
// "Open in chat" only makes sense for runs whose result is a real assistant
// message (Prompt / Research tasks). Action/event runs are just log lines
// (e.g. "No recent emails", "Tidied N memories") — for those, replace the
@@ -2179,6 +2258,19 @@ function _renderActivityEntry(entry) {
Copy log
</button>`;
}
const clearLabel = _taskClearCacheLabel(entry);
if (hasResult && clearLabel && entry.taskId) {
actionBtn += `<button class="task-log-clear-cache" type="button" title="Clear cached ${_escHtml(clearLabel)} for this task">
<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="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/></svg>
Clear cache
</button>`;
}
if (hasResult && entry.taskId) {
actionBtn += `<button class="task-log-run-again" type="button" title="Run this task again">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Run again
</button>`;
}
// Running rows replace the relative-time on the right with "Running NN" + a
// live whirlpool spinner. Queued shows "Queued" the same way (no timer —
// hasn't actually started yet). The elapsed counter ticks every second via
@@ -2191,7 +2283,8 @@ function _renderActivityEntry(entry) {
const startMs = entry.ts ? new Date(entry.ts).getTime() : Date.now();
const elapsedInit = isQueued ? '' : `<span class="task-log-running-elapsed" data-since="${startMs}">${_fmtElapsed(Date.now() - startMs)}</span>`;
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue" style="border:0;background:transparent;box-shadow:none;margin-left:5px;padding:0;width:12px;height:12px;display:inline-flex;align-items:center;justify-content:center;font-size:10px;line-height:1;color:inherit;opacity:.8;"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style="display:block;"><polygon points="6 4 20 12 6 20 6 4"/></svg></button>` : '';
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${forceBtn}</span>`;
const stopBtn = entry.taskId ? `<button class="task-log-stop" type="button" title="Stop this task"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg></button>` : '';
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${forceBtn}${stopBtn}</span>`;
} else {
rightHtml = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
}
@@ -2223,7 +2316,7 @@ function _renderActivityEntry(entry) {
<span style="flex:1"></span>
${rightHtml}
</div>
${_isRunning ? '' : `<div class="task-log-row-body">${resultHtml}</div>`}
${(_isRunning && !hasRunningProgress) ? '' : `<div class="task-log-row-body">${resultHtml}</div>`}
${promptHtml}
<div class="task-log-row-actions">
${long ? '<button class="task-log-row-toggle" type="button">Show more</button>' : '<span></span>'}