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

@@ -697,10 +697,9 @@
<div style="position:relative; display:inline-block; display:flex; gap:4px; align-items:center;">
<button type="button" class="section-header-btn chats-manage-btn" id="chats-library-btn" title="Manage Chats (Library)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
<path d="M9 7h6M9 11h4"/>
</svg>
</button>
<button type="button" class="section-header-btn" id="session-sort-btn" title="Sort sessions">
@@ -1878,6 +1877,14 @@
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" 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;opacity:0.6"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Email Tasks</h2>
<div class="settings-row" style="align-items:center;">
<div class="admin-toggle-sub" style="margin:0;flex:1;">Manage email background tasks in Tasks.</div>
<button class="admin-btn-add" id="set-email-open-tasks">Open Tasks</button>
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" 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;opacity:0.6"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Writing Style</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">AI-extracted from your sent emails. Used when AI drafts replies.</div>

View File

@@ -2306,6 +2306,48 @@ import * as Modals from './modalManager.js';
return r && r.style.display !== 'none' ? r : null;
}
function _stripEmailReplyQuoteText(text) {
const original = String(text || '');
if (!original) return { body: '', stripped: false };
const lines = original.split('\n');
const quoteIdx = lines.findIndex(line =>
/^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim())
|| /^On .+ wrote:\s*$/i.test(line.trim())
);
if (quoteIdx <= 0) return { body: original.trim(), stripped: false };
const body = lines.slice(0, quoteIdx).join('\n').trim();
return { body, stripped: !!body };
}
function _emailReplyOwnText(text) {
return _stripEmailReplyQuoteText(text).body;
}
function _setEmailBodyText(textarea, value) {
if (!textarea) return;
textarea.value = value || '';
syncHighlighting();
const rich = _emailRichbodyActive();
if (rich) rich.innerHTML = _emailBodyToHtml(textarea.value);
}
async function _streamEmailBodyText(textarea, value) {
if (!textarea) return;
const finalText = String(value || '');
const maxFrames = 90;
const chunk = Math.max(8, Math.ceil(finalText.length / maxFrames));
textarea.value = '';
const rich = _emailRichbodyActive();
if (rich) rich.innerHTML = '';
for (let i = 0; i < finalText.length; i += chunk) {
const next = finalText.slice(0, i + chunk);
textarea.value = next;
if (rich) rich.innerHTML = _emailBodyToHtml(next);
await new Promise(resolve => requestAnimationFrame(resolve));
}
_setEmailBodyText(textarea, finalText);
}
function _focusEmailBodyEnd() {
const target = _emailRichbodyActive() || document.getElementById('doc-editor-textarea');
if (!target) return;
@@ -2795,10 +2837,12 @@ import * as Modals from './modalManager.js';
const references = document.getElementById('doc-email-references')?.value?.trim();
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim();
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
const body = document.getElementById('doc-editor-textarea')?.value?.trim();
// WYSIWYG: the rich body's HTML becomes the email's HTML part (server
// sanitizes it). `body` (plain text mirror) stays the text/plain fallback.
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null;
const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token);
@@ -2806,6 +2850,10 @@ import * as Modals from './modalManager.js';
if (uiModule) uiModule.showError('To and body are required');
return;
}
if (inReplyTo && !_emailReplyOwnText(body)) {
if (uiModule) uiModule.showError('Reply body is empty');
return;
}
// Warn if body mentions attachments but none are actually attached
if (attachments.length === 0 && _bodyMentionsAttachment(body)) {
const proceed = await _confirmMissingAttachment();
@@ -2829,12 +2877,13 @@ import * as Modals from './modalManager.js';
let canceled = false;
if (uiModule) {
uiModule.showToast('Sending', {
duration: 1200,
duration: 3200,
leadingIcon: 'spinner',
action: 'Cancel',
onAction: () => { canceled = true; },
});
}
await _sleep(1000);
await _sleep(3000);
if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId);
await _sleep(200);
if (canceled) {
@@ -2844,28 +2893,10 @@ import * as Modals from './modalManager.js';
return;
}
let undone = false;
if (uiModule) {
uiModule.showToast('Message sent', {
duration: 2200,
leadingIcon: 'check',
action: 'Undo',
actionHint: 'undo send',
onAction: () => { undone = true; },
});
}
await _sleep(2200);
if (undone) {
_restoreDetachedEmailDoc(detachedEmailDoc);
detachedEmailDoc = null;
if (uiModule) uiModule.showToast('Send undone');
return;
}
if (uiModule) uiModule.showToast('Sending...', 2000);
const activeAccountId = await _resolveComposeSendAccountId();
const res = await fetch(`${API_BASE}/api/email/send`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to, cc: cc || null, bcc: bcc || null, subject, body, body_html: bodyHtml,
@@ -2875,7 +2906,13 @@ import * as Modals from './modalManager.js';
wait_for_delivery: true,
}),
});
const data = await res.json();
let data = null;
try {
data = await res.json();
} catch (_) {
data = { success: false, error: `Send failed (${res.status})` };
}
if (!res.ok && data && !data.error) data.error = `Send failed (${res.status})`;
if (data.success) {
if (uiModule) {
uiModule.showToast('Message sent', {
@@ -2961,8 +2998,10 @@ import * as Modals from './modalManager.js';
const subject = document.getElementById('doc-email-subject')?.value?.trim();
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim();
const references = document.getElementById('doc-email-references')?.value?.trim();
const body = document.getElementById('doc-editor-textarea')?.value?.trim();
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null;
const btn = document.getElementById('doc-email-draft-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
@@ -3074,6 +3113,32 @@ import * as Modals from './modalManager.js';
const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return;
const currentBody = textarea.value || '';
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim() || '';
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim() || '';
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
const cleanAiReplyText = (text) => {
if (!text) return '';
let t = String(text);
const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i;
const close = /<<<\s*END\s*>>+/i;
const m = open.exec(t);
if (m) {
const rest = t.slice(m.index + m[0].length);
const c = close.exec(rest);
t = c ? rest.slice(0, c.index) : rest;
}
return t
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
.replace(/<<<\s*END\s*>>+/gi, '')
.trim();
};
const shouldUseFastAiReply = () => {
const text = `${subject}\n${currentBody}`.toLowerCase();
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
return false;
}
return currentBody.length < 2500;
};
// Use the current chat model
let currentModel = '';
@@ -3096,22 +3161,24 @@ import * as Modals from './modalManager.js';
original_body: currentBody,
model: currentModel,
session_id: currentSessionId,
message_id: inReplyTo,
uid: sourceUid,
folder: sourceFolder,
fast: shouldUseFastAiReply(),
}),
});
const data = await res.json();
if (data.success && data.reply) {
const cleanReply = cleanAiReplyText(data.reply);
const lines = currentBody.split('\n');
const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:'));
let newBody = '';
if (quoteIdx > 0) {
const newBody = data.reply + '\n\n' + lines.slice(quoteIdx).join('\n');
textarea.value = newBody;
newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n');
} else {
textarea.value = data.reply + (currentBody ? '\n\n' + currentBody : '');
newBody = cleanReply + (currentBody ? '\n\n' + currentBody : '');
}
syncHighlighting();
// Mirror into the WYSIWYG rich body if it's the active editor.
const _rb = _emailRichbodyActive();
if (_rb) _rb.innerHTML = _emailBodyToHtml(textarea.value);
await _streamEmailBodyText(textarea, newBody);
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
} else {
if (uiModule) uiModule.showError(data.error || 'Failed to generate reply');
@@ -3130,7 +3197,12 @@ import * as Modals from './modalManager.js';
const subject = document.getElementById('doc-email-subject')?.value?.trim();
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim();
const references = document.getElementById('doc-email-references')?.value?.trim();
const body = document.getElementById('doc-editor-textarea')?.value?.trim();
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const body = (_rich
? (_rich.innerText || _rich.textContent || '')
: (document.getElementById('doc-editor-textarea')?.value || '')
).trim();
const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token);
@@ -3138,6 +3210,10 @@ import * as Modals from './modalManager.js';
if (uiModule) uiModule.showError('To and body are required');
return;
}
if (inReplyTo && !_emailReplyOwnText(body)) {
if (uiModule) uiModule.showError('Reply body is empty');
return;
}
if (attachments.length === 0 && _bodyMentionsAttachment(body)) {
const proceed = await _confirmMissingAttachment();
if (!proceed) return;
@@ -5680,6 +5756,41 @@ import * as Modals from './modalManager.js';
}));
}
export async function replaceEmailReplyBody(docId, replyText) {
const doc = docs.get(docId);
if (!doc) return;
const fields = _parseEmailHeader(doc.content || '');
const lines = String(fields.body || '').split('\n');
const quoteIdx = lines.findIndex(line =>
/^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim())
|| /^On .+ wrote:\s*$/i.test(line.trim())
);
const quote = quoteIdx >= 0 ? lines.slice(quoteIdx).join('\n') : '';
const ownText = _emailReplyOwnText(fields.body || '');
if (ownText && !/^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText)) {
if (uiModule) uiModule.showToast('AI reply ready, but draft was edited');
return;
}
const body = String(replyText || '').trim() + (quote ? `\n\n${quote}` : '');
doc.content = _buildEmailContent(
fields.to,
fields.subject,
fields.inReplyTo,
fields.references,
body,
fields.sourceUid,
fields.sourceFolder,
fields.cc,
fields.bcc,
);
if (activeDocId === docId) {
const textarea = document.getElementById('doc-editor-textarea');
if (textarea) await _streamEmailBodyText(textarea, body);
}
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
}
// Force the panel into a genuinely-open state. `isOpen` can be true while the
// pane was torn down by another full-screen view (e.g. opening a doc from the
// email modal): in that case openPanel() early-returns and nothing mounts, so

View File

@@ -26,6 +26,36 @@ const _starIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" s
const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _replySeparator = '---------- Previous message ----------';
function _cleanAiReplyText(text) {
if (!text) return '';
let t = String(text);
const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i;
const close = /<<<\s*END\s*>>+/i;
const m = open.exec(t);
if (m) {
const rest = t.slice(m.index + m[0].length);
const c = close.exec(rest);
t = c ? rest.slice(0, c.index) : rest;
}
return t
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
.replace(/<<<\s*END\s*>>+/gi, '')
.trim();
}
function _shouldUseFastAiReply(data) {
const body = String(data?.body || data?.body_html || '');
const subject = String(data?.subject || '');
const atts = Array.isArray(data?.attachments) ? data.attachments : [];
if (atts.length > 0) return false;
const text = `${subject}\n${body}`.toLowerCase();
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
return false;
}
return body.length < 2500;
}
let _emails = [];
let _currentFolder = 'INBOX';
@@ -609,52 +639,9 @@ function _createEmailItem(em) {
}
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
// If AI Reply mode: use cached reply if available, otherwise generate
const wantsAiReply = mode === 'ai-reply';
let aiSuggestedBody = null;
if (mode === 'ai-reply' && preloadedData) {
const data = preloadedData;
// Check for pre-generated cached reply first (instant!)
if (data.cached_ai_reply) {
aiSuggestedBody = data.cached_ai_reply;
} else {
// No cache — generate on demand
try {
let currentModel = '';
let currentSessionId = '';
try {
currentModel = sessionModule?.getCurrentModel() || '';
currentSessionId = sessionModule?.getCurrentSessionId() || '';
} catch (_) {}
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: data.from_address,
subject: `Re: ${data.subject}`,
original_body: data.body,
model: currentModel,
session_id: currentSessionId,
message_id: data.message_id || '',
uid: String(em.uid || ''),
folder: _currentFolder,
}),
});
const result = await res.json();
if (result.success && result.reply) {
aiSuggestedBody = result.reply;
} else {
// Don't silently open a blank draft — tell the user it failed so a
// model/endpoint problem (e.g. empty response) is visible.
// uiModule isn't statically imported here; use the dynamic pattern.
const _msg = result.error || 'AI reply could not be generated';
console.error('AI reply generation failed:', _msg);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {});
}
} catch (e) {
console.error('AI reply generation failed:', e);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
}
}
if (wantsAiReply) {
// Fall through to reply-all (not plain reply) so the generated AI
// draft addresses everyone on the original thread. On single-
// recipient emails this collapses to a regular reply since there's
@@ -682,6 +669,54 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
console.error('Failed to read email:', data.error);
return;
}
if (wantsAiReply) {
if (data.cached_ai_reply) {
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
} else {
let draftToastTimer = null;
draftToastTimer = setTimeout(() => {
import('./ui.js').then(m => m.showToast && m.showToast('Drafting AI reply', { duration: 3000, leadingIcon: 'spinner' })).catch(() => {});
}, 450);
try {
let currentModel = '';
let currentSessionId = '';
try {
currentModel = sessionModule?.getCurrentModel() || '';
currentSessionId = sessionModule?.getCurrentSessionId() || '';
} catch (_) {}
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: data.from_address,
subject: `Re: ${data.subject}`,
original_body: data.body,
model: currentModel,
session_id: currentSessionId,
message_id: data.message_id || '',
uid: String(em.uid || ''),
folder: _currentFolder,
fast: _shouldUseFastAiReply(data),
}),
});
const result = await res.json();
if (draftToastTimer) clearTimeout(draftToastTimer);
if (result.success && result.reply) {
aiSuggestedBody = _cleanAiReplyText(result.reply);
} else {
const _msg = result.error || 'AI reply could not be generated';
console.error('AI reply generation failed:', _msg);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {});
return;
}
} catch (e) {
if (draftToastTimer) clearTimeout(draftToastTimer);
console.error('AI reply generation failed:', e);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
return;
}
}
}
em.is_read = true;
if (itemEl) itemEl.classList.remove('email-unread');
@@ -772,7 +807,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
} else {
content += '\n\n';
}
content += `On ${niceDate}, ${data.from_name} <${data.from_address}> wrote:\n${quotedBody}`;
content += `${_replySeparator}\nOn ${niceDate}, ${data.from_name} <${data.from_address}> wrote:\n${quotedBody}`;
}
if (_docModule) {

View File

@@ -84,8 +84,6 @@ window.addEventListener('email-answered', (e) => {
function _toggleUnreadEmails() {
if (state._libFolder === '__scheduled__') state._libFolder = 'INBOX';
state._libFilter = state._libFilter === 'unread' ? 'all' : 'unread';
state._libOffset = 0;
state._libEmails = [];
_syncUnreadWindowGlow();
const folderEl = document.getElementById('email-lib-folder');
const filterEl = document.getElementById('email-lib-filter');
@@ -93,7 +91,7 @@ function _toggleUnreadEmails() {
if (filterEl) filterEl.value = state._libFilter;
document.getElementById('email-undone-btn')?.classList.remove('active');
document.getElementById('email-reminder-btn')?.classList.remove('active');
_loadEmails();
_loadEmailsFresh();
}
function _syncUnreadTabBadge(count) {
@@ -433,6 +431,22 @@ function _libCachePut(key, value) {
}
}
function _resetEmailListForFreshLoad() {
state._libOffset = 0;
state._libEmails = [];
state._libTotal = 0;
_libLoadSeq += 1;
const grid = document.getElementById('email-lib-grid');
if (grid) grid.innerHTML = '';
const stats = document.getElementById('email-lib-stats');
if (stats) stats.textContent = 'Loading...';
}
function _loadEmailsFresh() {
_resetEmailListForFreshLoad();
return _loadEmails({ force: true, useCache: false });
}
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
if (_libPrewarmTimer || _libPrewarmPromise) return;
const elapsed = Date.now() - _libLastPrewarmAt;
@@ -742,17 +756,13 @@ export function openEmailLibrary(opts = {}) {
document.getElementById('email-lib-folder').addEventListener('change', (e) => {
state._libFolder = e.target.value;
state._libOffset = 0;
state._libEmails = [];
_loadEmails();
_loadEmailsFresh();
});
document.getElementById('email-lib-filter').addEventListener('change', (e) => {
state._libFilter = e.target.value;
state._libOffset = 0;
state._libEmails = [];
_syncUnreadWindowGlow();
_syncReminderClearButton();
_loadEmails();
_loadEmailsFresh();
// Sync quick-toggle active states so they mirror the dropdown.
document.getElementById('email-undone-btn')?.classList.toggle('active', state._libFilter === 'undone');
document.getElementById('email-reminder-btn')?.classList.toggle('active', state._libFilter === 'reminders');
@@ -761,10 +771,8 @@ export function openEmailLibrary(opts = {}) {
const btn = document.getElementById('email-attach-btn');
state._libHasAttachments = !state._libHasAttachments;
btn?.classList.toggle('active', state._libHasAttachments);
state._libOffset = 0;
state._libEmails = [];
_syncReminderClearButton();
_loadEmails();
_loadEmailsFresh();
});
document.getElementById('email-reminders-clear-btn')?.addEventListener('click', async () => {
const ok = await styledConfirm('Permanently delete all Odysseus reminder emails?', {
@@ -790,10 +798,8 @@ export function openEmailLibrary(opts = {}) {
const filterEl = document.getElementById('email-lib-filter');
if (filterEl) filterEl.value = 'all';
document.getElementById('email-reminder-btn')?.classList.remove('active');
state._libOffset = 0;
state._libEmails = [];
_syncReminderClearButton();
_loadEmails();
_loadEmailsFresh();
} catch (err) {
console.error(err);
showToast('Failed to clear reminder emails');
@@ -812,11 +818,9 @@ export function openEmailLibrary(opts = {}) {
btn.classList.add('active');
document.getElementById('email-reminder-btn')?.classList.remove('active');
}
state._libOffset = 0;
state._libEmails = [];
_syncUnreadWindowGlow();
_syncReminderClearButton();
_loadEmails();
_loadEmailsFresh();
});
document.getElementById('email-reminder-btn')?.addEventListener('click', () => {
const btn = document.getElementById('email-reminder-btn');
@@ -831,11 +835,9 @@ export function openEmailLibrary(opts = {}) {
btn.classList.add('active');
document.getElementById('email-undone-btn')?.classList.remove('active');
}
state._libOffset = 0;
state._libEmails = [];
_syncUnreadWindowGlow();
_syncReminderClearButton();
_loadEmails();
_loadEmailsFresh();
});
// The old "sort" dropdown (Latest / Unread first / Favorites first) was merged
// into the filter dropdown above — "Favorites" is now a filter (server-side
@@ -1081,8 +1083,6 @@ function _renderAccountsStrip() {
const strip = document.getElementById('email-lib-accounts');
if (!strip) return;
strip.style.display = 'flex';
// No accounts loaded yet — leave the row empty (New button still shows alongside).
if (!state._libAccounts.length) { strip.innerHTML = ''; return; }
const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const allActive = !state._libAccountId ? ' active' : '';
let html = `<button class="memory-toolbar-btn gallery-chip${allActive}" data-acc-id="">All (default)</button>`;
@@ -1096,11 +1096,10 @@ function _renderAccountsStrip() {
btn.addEventListener('click', async () => {
state._libAccountId = btn.dataset.accId || null;
_publishActiveAccount();
state._libOffset = 0;
state._libEmails = [];
_resetEmailListForFreshLoad();
_renderAccountsStrip();
await _loadFolders({ resetMissing: true });
_loadEmails({ force: true });
_loadEmails({ force: true, useCache: false });
});
});
_publishActiveAccount();
@@ -1358,7 +1357,7 @@ async function _refreshUnreadBadge() {
} catch (_) { _syncUnreadTabBadge(0); }
}
async function _loadEmails({ force = false } = {}) {
async function _loadEmails({ force = false, useCache = true } = {}) {
const seq = ++_libLoadSeq;
state._libLoading = true;
const accountAtStart = state._libAccountId || '';
@@ -1375,15 +1374,16 @@ async function _loadEmails({ force = false } = {}) {
// paint the cached list immediately (no spinner, no blank grid) and
// then quietly refetch behind it. Pagination, search, and the
// scheduled virtual folder skip the cache and use the old spinner
// path. `force` (Refresh button) still consults the cache for
// path. `force` (Refresh button) can still consult the cache for
// perceptual continuity, but adds a cache-buster so the server's 8s
// list cache is bypassed too.
// list cache is bypassed too. Account/folder/filter changes pass
// `useCache: false` so stale rows from the previous view never flash.
const cacheable =
offsetAtStart === 0 &&
!searchAtStart &&
folderAtStart !== '__scheduled__';
const ck = cacheable ? _libCacheKey() : null;
const cached = cacheable ? _libCacheGet(ck) : null;
const cached = (useCache && cacheable) ? _libCacheGet(ck) : null;
let sp = null;
if (cached) {
@@ -1881,6 +1881,9 @@ function _prefetchAdjacentEmails(card, count = 3) {
}
async function _toggleCardPreview(card, em) {
const accountAtStart = state._libAccountId || '';
const folderAtStart = state._libFolder || 'INBOX';
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
const grid = card.closest('.doclib-grid');
const gridRect = grid?.getBoundingClientRect?.();
const modal = document.getElementById('email-lib-modal');
@@ -1921,7 +1924,7 @@ async function _toggleCardPreview(card, em) {
card.style.minHeight = `${Math.round(stableOpenHeight)}px`;
if (!em.is_read) {
_syncEmailReadState(em.uid, true);
fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' })
fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`, { method: 'POST' })
.catch(err => console.error('Failed to mark email read:', err));
}
// Class hook on the modal so the header-hide / padding rules work on
@@ -1944,8 +1947,17 @@ async function _toggleCardPreview(card, em) {
card.appendChild(reader);
try {
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`);
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
const data = await res.json();
if (
accountAtStart !== (state._libAccountId || '') ||
folderAtStart !== (state._libFolder || 'INBOX') ||
uidAtStart !== String(card?.dataset?.uid || '') ||
!card.isConnected ||
!card.classList.contains('email-card-expanded')
) {
return;
}
if (data.error) {
reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Error: ${_esc(data.error)}</div>`;
return;
@@ -2013,7 +2025,7 @@ async function _toggleCardPreview(card, em) {
</div>
</div>
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_renderEmailBody(data)}</div>
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
reader.classList.remove('email-card-reader-loading');
reader.style.minHeight = '';
@@ -2252,6 +2264,23 @@ function _setBubblesDisabled(v) {
}
function _renderEmailBody(data) {
const plain = (typeof data?.body === 'string' && data.body.length) ? data.body : '';
const folder = String(data?.folder || '').toLowerCase();
const isSentFolder = folder.includes('sent');
const fromAddr = String(data?.from_address || '').toLowerCase().trim();
const isMine = !!fromAddr && _meEmailAddrs().has(fromAddr);
// Messages authored by the user (Sent folder or self-sent copies in INBOX)
// are current authored text. Do not let cached boundaries or HTML
// blockquote parsing hide the whole thing behind "Earlier reply".
if ((isSentFolder || isMine) && plain) {
const plainTurns = _renderPlaintextThread(plain);
if (plainTurns && !/^\s*<details\b/i.test(plainTurns.trim())) {
return _foldSignature(plainTurns, null);
}
return _foldSignature(_escLinkify(plain).replace(/\n/g, '<br>'), null);
}
// Prefer the server-cached thread parse — that's the richest structure
// and the one the chat-bubble layout is built around. Skip when the user
// has manually disabled bubble rendering.
@@ -2263,7 +2292,6 @@ function _renderEmailBody(data) {
}
const b = data && data.boundaries;
// Use cached boundaries when present AND we have plain-text body to slice
const plain = (typeof data.body === 'string' && data.body.length) ? data.body : '';
if (b && plain && (b.sig_start >= 0 || b.quote_start >= 0)) {
// Pick the EARLIER of the two as the cut for "everything below this is
// foldable", but render sig and quote with their own labels.
@@ -2327,6 +2355,18 @@ function _renderEmailBody(data) {
return _foldSignature(_foldQuotedReplies(rendered), hintSig);
}
function _safeRenderEmailBody(data) {
try {
return _renderEmailBody(data);
} catch (e) {
console.error('email body render failed:', e);
const plain = (typeof data?.body === 'string') ? data.body : '';
if (plain) return _escLinkify(plain).replace(/\n/g, '<br>');
if (data?.body_html) return _sanitizeHtml(data.body_html);
return '<span style="opacity:.65">No body</span>';
}
}
// ── Chat-bubble rendering for email threads ──
// Each parsed turn renders as a chat bubble. Bubbles for the active
// account's outgoing replies align right; everyone else aligns left.
@@ -2636,12 +2676,13 @@ function _renderPlaintextThread(text) {
const lvl = levels[i];
const raw = lines[i];
const stripped = lvl > 0 ? raw.replace(/^(?:>\s?)+/, '') : raw;
const isSeparatorLine = lvl === 0 && /^-{5,}\s*Previous message\s*-{5,}$/i.test(raw.trim());
const isAttribLine = lvl === 0
&& (new RegExp(`^\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'i').test(raw)
|| _TALON_ORIG_RE.test('\n' + raw));
if (isAttribLine) {
if (isSeparatorLine || isAttribLine) {
flush();
pendingMeta = _extractQuoteMeta(raw) || raw.trim();
pendingMeta = isSeparatorLine ? null : (_extractQuoteMeta(raw) || raw.trim());
curLevel = 1;
continue;
}
@@ -3699,7 +3740,7 @@ async function _openEmailAsTab(em, folder) {
</div>
</div>
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_renderEmailBody(data)}</div>
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
try { _wireAttachmentHandlers(reader, useFolder); } catch {}
const attsWrap = reader.querySelector('.email-reader-atts-wrap');
@@ -3854,7 +3895,7 @@ async function _openEmailWindow(em, folder) {
</div>
</div>
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_renderEmailBody(data)}</div>
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
// Wire all the same action handlers the inline reader has.
try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {}
@@ -3971,7 +4012,7 @@ async function _swapReaderToUid(reader, uid, folder) {
} else if (oldAtts) {
oldAtts.remove();
}
body.innerHTML = _renderEmailBody(data);
body.innerHTML = _safeRenderEmailBody(data);
body.classList.toggle('html-body', !!data.body_html);
// Wire click handlers for the newly-rendered attachment chips. Without
// this, after swapping to a different email via the sidebar, clicking

View File

@@ -2457,6 +2457,20 @@ async function initEmailAccountsSettings() {
manageBtn.dataset.bound = '1';
manageBtn.addEventListener('click', () => open('integrations'));
}
const tasksBtn = el('set-email-open-tasks');
if (tasksBtn && tasksBtn.dataset.bound !== '1') {
tasksBtn.dataset.bound = '1';
tasksBtn.addEventListener('click', async () => {
try {
const mod = await import('./tasks.js');
const openTasks = mod.openTasks || (mod.default && mod.default.openTasks);
if (typeof openTasks === 'function') openTasks();
else document.getElementById('tool-tasks-btn')?.click();
} catch (_) {
document.getElementById('tool-tasks-btn')?.click();
}
});
}
const listEl = el('set-email-accounts-list');
const msgEl = el('set-email-accounts-msg');
const formEl = el('set-email-accounts-form');

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>'}

View File

@@ -6,12 +6,15 @@
import themeModule from './theme.js';
import * as Modals from './modalManager.js';
import spinnerModule from './spinner.js';
let toastEl = null;
let autoScrollEnabled = true;
let hoveredToggleCard = null;
let hoveredToggleWindow = null;
let hoveredDockChip = null;
let _lastPointerClientX = null;
let _lastPointerClientY = null;
// Smooth scroll state
let _scrollRafId = null;
@@ -74,6 +77,66 @@ function _spaceWindowId(win) {
return null;
}
function _windowAtPointer() {
if (_lastPointerClientX == null || _lastPointerClientY == null) return null;
const x = _lastPointerClientX;
const y = _lastPointerClientY;
const candidates = [
...document.querySelectorAll('.modal:not(.hidden):not(.modal-minimized) .modal-content'),
...document.querySelectorAll('.doc-editor-pane'),
].filter(el => {
if (!document.contains(el)) return false;
const r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
});
if (!candidates.length) return null;
return candidates.reduce((top, el) => {
const mz = parseInt(getComputedStyle(el.closest('.modal') || el).zIndex, 10) || 0;
const tz = parseInt(getComputedStyle(top.closest('.modal') || top).zIndex, 10) || 0;
return mz >= tz ? el : top;
});
}
function _containsPointer(el) {
if (!el || _lastPointerClientX == null || _lastPointerClientY == null) return false;
const r = el.getBoundingClientRect();
return _lastPointerClientX >= r.left && _lastPointerClientX <= r.right
&& _lastPointerClientY >= r.top && _lastPointerClientY <= r.bottom;
}
function _closeHoveredWindow() {
let win = _windowAtPointer();
if (!win) {
try {
const underPointer = document.elementFromPoint(_lastPointerClientX, _lastPointerClientY);
win = underPointer?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane') || null;
} catch {}
}
if (!win) win = hoveredToggleWindow;
if (!win || !document.contains(win)) return false;
const modalForWin = win.closest?.('.modal[id]');
if (modalForWin?.id === 'email-lib-modal') {
const closeBtn = document.getElementById('email-lib-close') || modalForWin.querySelector('.close-btn');
if (closeBtn) {
try { closeBtn.click(); return true; } catch {}
}
try { modalForWin.remove(); return true; } catch {}
}
const id = _spaceWindowId(win);
if (id && Modals.isRegistered(id)) {
Modals.close(id);
return true;
}
const modal = _visibleModalForSpace(win);
if (!modal) return false;
const closeBtn = modal.querySelector('.close-btn, .modal-close, .modal-close-btn, [data-action="close"]');
if (closeBtn) {
try { closeBtn.click(); return true; } catch {}
}
try { modal.classList.add('hidden'); return true; } catch {}
return false;
}
function _spaceIsBlocked(e, surface) {
const target = _targetEl(e.target);
if (!target) return false;
@@ -103,6 +166,8 @@ function _initHoverCardSpaceToggle() {
if (document._odysseusHoverCardSpaceToggle) return;
document._odysseusHoverCardSpaceToggle = true;
document.addEventListener('pointerover', (e) => {
_lastPointerClientX = e.clientX;
_lastPointerClientY = e.clientY;
const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id]');
if (chip) hoveredDockChip = chip;
const card = e.target?.closest?.(SPACE_CARD_SELECTOR);
@@ -110,6 +175,10 @@ function _initHoverCardSpaceToggle() {
const win = e.target?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane');
if (win) hoveredToggleWindow = win;
}, true);
document.addEventListener('pointermove', (e) => {
_lastPointerClientX = e.clientX;
_lastPointerClientY = e.clientY;
}, true);
document.addEventListener('pointerout', (e) => {
const next = e.relatedTarget;
if (hoveredDockChip && (!next || !hoveredDockChip.contains(next))) hoveredDockChip = null;
@@ -252,6 +321,12 @@ export function showToast(msg, durationOrOpts) {
icon.className = 'toast-checkmark';
icon.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
toastEl.appendChild(icon);
} else if (leadingIcon === 'spinner') {
const wp = spinnerModule.createWhirlpool(14);
const icon = wp.element;
icon.classList.add('toast-whirlpool');
icon.style.cssText = 'width:14px;height:14px;margin:0 8px 0 0;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;';
toastEl.appendChild(icon);
}
textSpan.textContent = msg;
toastEl.appendChild(textSpan);
@@ -1114,8 +1189,6 @@ if (!window._odyEscExpandGuard) {
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || e.defaultPrevented) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// Find the single thing to close, in priority order. The first hit wins.
// Important: if a thinking block is open we MUST handle it ourselves and
@@ -1123,6 +1196,12 @@ if (!window._odyEscExpandGuard) {
// (the live-stream chat rebuilds thinking DOM mid-stream so the header
// can briefly be absent). Toggling the `expanded` class directly is the
// fallback so ESC never bypasses the thinking block to hit a modal.
if (_closeHoveredWindow()) {
e.stopImmediatePropagation(); e.preventDefault();
return;
}
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
const expanded = document.querySelector('.doclib-card-expanded');
const think = document.querySelector('.thinking-content.expanded');
if (expanded) {

View File

@@ -3533,6 +3533,11 @@ body.bg-pattern-sparkles {
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
backdrop-filter: blur(12px);
max-width: min(360px, calc(100vw - 32px));
min-width: min(220px, calc(100vw - 32px));
min-height: 34px;
display: inline-flex;
align-items: center;
box-sizing: border-box;
}
.toast.show { opacity:1; transform: translateX(0); }
.toast .toast-checkmark {
@@ -9984,6 +9989,17 @@ textarea.memory-add-input {
background: color-mix(in srgb, var(--green, #50fa7b) 20%, transparent);
border-color: color-mix(in srgb, var(--green, #50fa7b) 35%, transparent);
}
.task-run-now-badge {
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 16%, transparent);
border-color: color-mix(in srgb, var(--accent, var(--red)) 34%, transparent);
}
.task-card-run-btn {
appearance: none;
height: 20px;
min-height: 0;
box-sizing: border-box;
}
.task-status-badge:hover {
filter: brightness(1.08) saturate(1.15);
}
@@ -9995,6 +10011,10 @@ textarea.memory-add-input {
background: color-mix(in srgb, var(--green, #50fa7b) 28%, transparent);
border-color: color-mix(in srgb, var(--green, #50fa7b) 55%, transparent);
}
.task-run-now-badge:hover {
background: color-mix(in srgb, var(--accent, var(--red)) 24%, transparent);
border-color: color-mix(in srgb, var(--accent, var(--red)) 52%, transparent);
}
.task-builtin-badge {
font-size: 9px;
@@ -20518,11 +20538,10 @@ body:not(.welcome-ready) #welcome-screen {
margin-bottom: 0;
}
.task-log-row.expanded .task-log-row-head { margin-bottom: 4px; }
/* Collapsed: body + footer hidden. Expanded: visible. Running/skipped rows
don't expand at all (no body to show). */
.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-row-body,
.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-row-actions,
.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-prompt {
/* Collapsed: body + footer hidden. Expanded: visible. */
.task-log-row:not(.expanded):not(.is-skipped) .task-log-row-body,
.task-log-row:not(.expanded):not(.is-skipped) .task-log-row-actions,
.task-log-row:not(.expanded):not(.is-skipped) .task-log-prompt {
display: none;
}
.task-log-name {
@@ -20571,6 +20590,26 @@ body:not(.welcome-ready) #welcome-screen {
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
.task-log-stop {
border: 0;
background: transparent;
color: inherit;
opacity: .72;
padding: 0;
margin-left: 6px;
width: 12px;
height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
top: -2px;
}
.task-log-stop:hover {
opacity: 1;
color: var(--red, #f87171);
}
/* Slim single-line row for skipped (noop) runs body/actions stripped, font
shrunk, opacity dropped. Distinguishes "task ran but had nothing to do"
@@ -20718,7 +20757,10 @@ body:not(.welcome-ready) #welcome-screen {
margin-top: 4px;
}
.task-log-open-chat,
.task-log-copy {
.task-log-open-report,
.task-log-copy,
.task-log-clear-cache,
.task-log-run-again {
display: inline-flex;
align-items: center;
gap: 3px;
@@ -20734,11 +20776,22 @@ body:not(.welcome-ready) #welcome-screen {
line-height: 1.4;
}
.task-log-open-chat:hover,
.task-log-copy:hover {
.task-log-open-report:hover,
.task-log-copy:hover,
.task-log-clear-cache:hover,
.task-log-run-again:hover {
color: var(--fg);
border-color: color-mix(in srgb, var(--fg) 30%, transparent);
background: color-mix(in srgb, var(--fg) 5%, transparent);
}
.task-log-row-actions > .task-log-open-chat,
.task-log-row-actions > .task-log-copy {
margin-left: auto;
}
.task-log-clear-cache svg {
position: relative;
top: 2px;
}
/* Activity filter chips toggle-out model: ON by default (solid),
click to toggle OFF (dimmed + strikethrough) to hide that group. */
.tasks-af-chip {
@@ -27694,7 +27747,7 @@ body.doc-find-active mark.doc-find-mark.current {
}
/* Cc toggle and attach button are absolute so they don't steal width from the To input */
.email-field .email-cc-toggle {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
position: absolute; right: 6px; top: calc(50% + 4px); transform: translateY(-50%);
z-index: 2;
}
.email-field input { padding-right: 60px; }