Polish email tasks and window controls
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>'}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user