Files
odysseus/static/js/emailInbox.js

1375 lines
58 KiB
JavaScript
Raw Permalink Normal View History

2026-05-31 23:58:26 +09:00
/**
* emailInbox.js — Email inbox list in sidebar.
* Follows the session list pattern: list items, click to open as document, archive, etc.
*/
import spinnerModule from './spinner.js';
import sessionModule from './sessions.js';
2026-06-30 01:28:08 +00:00
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen } from './emailLibrary.js';
2026-05-31 23:58:26 +09:00
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
2026-07-07 00:50:07 +00:00
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
2026-06-27 13:05:44 +00:00
import { emailApiUrl, emailAccountQuery } from './emailShared.js';
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
2026-05-31 23:58:26 +09:00
const API_BASE = window.location.origin;
2026-06-27 13:05:44 +00:00
const _acct = () => emailAccountQuery('&');
2026-05-31 23:58:26 +09:00
const _emailSetupHint = () => '<div style="margin-top:6px;opacity:0.72;font-size:11px;">Setup: <span style="color:var(--accent,var(--red));">Settings &rsaquo; Integrations</span></div>';
// SVG icons matching sessions.js dropdown style
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
const _archiveIcon = '<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="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
const _deleteIcon = '<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="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _starIcon = '<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="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
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"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
2026-05-31 23:58:26 +09:00
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>`;
2026-06-01 20:56:11 +09:00
const _replySeparator = '---------- Previous message ----------';
2026-06-27 13:05:44 +00:00
const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']);
2026-07-07 00:50:07 +00:00
function _splitEmailAddresses(raw) {
return (typeof raw === 'string' ? raw : '')
.split(',')
.map((x) => x.trim())
.filter(Boolean);
}
function _isMyEmailAddress(addr, myAddresses) {
const email = extractEmail(addr);
if (!email) return false;
return new Set((myAddresses || []).map(a => String(a || '').trim().toLowerCase()).filter(Boolean)).has(email);
}
function _withoutMyAddresses(raw, myAddresses) {
return _splitEmailAddresses(raw).filter(addr => !_isMyEmailAddress(addr, myAddresses));
}
2026-06-27 13:05:44 +00:00
function _openCalendarEventFromEmail(uid) {
const target = String(uid || '').trim();
if (!target) return;
import('./calendar.js').then(mod => {
const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo);
if (open) open(target);
}).catch(() => {});
}
function _openEmailTagFilter(tag) {
const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-');
if (!normalized || normalized === 'calendar') return;
try { openEmailLibrary(); } catch (_) {}
setTimeout(() => {
document.dispatchEvent(new CustomEvent('odysseus:email-filter-tag', { detail: { tag: normalized } }));
}, 0);
}
function _emailTagPillHtml(tag, em) {
const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-');
if (!normalized) return '';
const eventUid = normalized === 'calendar' && Array.isArray(em?.calendar_event_uids)
? String(em.calendar_event_uids[0] || '').trim()
: '';
if (normalized === 'calendar') {
if (!eventUid) return '';
return `<button type="button" class="email-tag email-tag-${_esc(normalized)} email-tag-clickable" data-calendar-event-uid="${_esc(eventUid)}" title="Open calendar event">${_esc(normalized)}</button>`;
}
return `<button type="button" class="email-tag email-tag-${_esc(normalized)} email-tag-clickable" data-email-filter-tag="${_esc(normalized)}" title="Show ${_esc(normalized)} emails">${_esc(normalized)}</button>`;
}
function _emailTagGroupHtml(tags, em) {
const visible = (Array.isArray(tags) ? tags : [])
.map(t => _emailTagPillHtml(t, em))
.filter(Boolean);
if (!visible.length) return '';
if (visible.length === 1) return `<span class="email-tags">${visible[0]}</span>`;
const extra = visible.slice(1).map(html => `<span class="email-tag-extra">${html}</span>`).join('');
return `<span class="email-tags email-tags-collapsed">${visible[0]}${extra}<button type="button" class="email-tags-more" data-email-tags-more aria-expanded="false" title="Show all tags">+${visible.length - 1}<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button></span>`;
}
function _visibleEmailTagsForRender(em) {
const tags = Array.isArray(em?.tags) ? em.tags : [];
if (!em?.is_answered) return tags;
return tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
}
function _clearDoneResponseTagsLocal(em) {
if (!em || !Array.isArray(em.tags)) return;
em.tags = em.tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
}
2026-06-01 20:56:11 +09:00
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;
}
2026-05-31 23:58:26 +09:00
let _emails = [];
let _currentFolder = 'INBOX';
let _offset = 0;
let _total = 0;
// Replying to an email marks the source \Answered server-side and fires
// `email-answered`. Reflect it live in the inbox list so it shows as done
// immediately (no manual refresh needed).
window.addEventListener('email-answered', (e) => {
const uid = e.detail && e.detail.uid;
if (uid == null) return;
const em = _emails.find(x => String(x.uid) === String(uid));
2026-06-27 13:05:44 +00:00
if (em) {
em.is_answered = true;
em.is_read = true;
_clearDoneResponseTagsLocal(em);
}
2026-05-31 23:58:26 +09:00
document.querySelectorAll('.email-item[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(item => {
item.classList.remove('email-unread');
2026-06-27 13:05:44 +00:00
item.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
2026-05-31 23:58:26 +09:00
const check = item.querySelector('.email-done-check');
if (check) check.classList.add('active');
// Auto-mark from sending a reply — flash the row so the user sees the
// state change without staring at it. Class self-removes after the
// animation so it doesn't replay on re-renders.
item.classList.add('email-auto-done-flash');
setTimeout(() => item.classList.remove('email-auto-done-flash'), 1200);
2026-05-31 23:58:26 +09:00
});
});
let _loading = false;
let _expanded = false;
let _docModule = null;
let _listSpinner = null;
let _senderFilter = null; // email address (lowercased) to filter by, or null
let _senderFilterLabel = null; // display label for the active filter chip
2026-06-27 13:05:44 +00:00
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
2026-05-31 23:58:26 +09:00
export function init(documentModule) {
_docModule = documentModule;
_bindEvents();
2026-06-27 13:05:44 +00:00
document.addEventListener('odysseus:email-tags-toggle', (e) => {
_showEmailTags = e.detail?.show !== false;
_renderList();
});
2026-05-31 23:58:26 +09:00
// Init the library popup with a callback to open emails
initEmailLibrary({
documentModule,
onEmailClick: async (opts) => {
// Reply / AI Reply / Compose open a draft in the doc editor.
// - Desktop: dock the email to the LEFT so it stays visible beside the
// reply draft (which opens on the right) — read-while-you-reply.
// - Mobile: there's no room for a split, so minimize the email modal;
// the draft comes to the front and the inbox stays a tap away as a
// minimized chip.
// Never call closeEmailLibrary() here — that destroys state.
try {
if (Modals.isRegistered('email-lib-modal')) {
const emailModal = document.getElementById('email-lib-modal');
if (window.innerWidth > 768 && emailModal && !emailModal.classList.contains('hidden')) {
applyEdgeDock(emailModal, 'left');
}
// Mobile: do NOT pre-mount the pane here. The load path (open/inject)
// mounts it exactly once when the doc is ready; the doc-view z-index
// rule slides it up OVER the email (which stays behind). Pre-mounting
// here caused a double-mount — the early pane was torn down by the
// compose session-switch, then remounted, which looked like a doc
// flashing before the smooth slide.
}
} catch (_) {}
if (opts.compose) { _composeNew(); return; }
if (opts.email) {
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '');
2026-05-31 23:58:26 +09:00
}
},
});
_watchDocOpenToReDockEmail();
}
Open email context for agent, email search across All Mail, cookbook serve polish - Agent: pass the open email reader (uid/folder/account/from/subject/body preview) on every chat submit so 'reply to this' / 'write email saying hi' route to ui_control open_email_reply with the right UID instead of inventing a new .md draft. Code-level enforcement (chat_routes strips create_document + send_email when active_email is set); cross-session active_doc_id is now trusted instead of being silently dropped. set_active_email/clear_active_email tool-layer helpers in tool_implementations. - ui_control open_email_reply: optional body argument so the agent can open-and-write in one call; envelope now forwards uid/folder/account/ body/panel through tool_output. Tool description sharpened and the parser rejects empty bodies on reply/reply-all (forces the agent to write rather than open an empty draft). - Email library: search now runs against [Gmail]/All Mail when the current folder is INBOX (archived emails surface). Whirlpool spinner + 'Searching…' placeholder while in flight. Each search result is stamped with its source folder so clicks open the right email instead of whatever shares its UID in INBOX. Search no longer re-applies the same text pill locally (which only checks subject/from/snippet, never body) so body-only matches don't get dropped after IMAP returns them. Initial inbox load bumped 100→500. - Email favorites: 'Favorite (pin to top)' / 'Unfavorite' in both the card menu and the open-reader more menu, backed by a new /api/email/flag/{uid}?on=true|false endpoint. Flagged emails always bubble to the top of the grid regardless of active sort. - AI reply in doc editor: never overwrites existing draft text or the quoted history. AI suggestion is prepended; AI-generated 'On … wrote:' re-quotes are stripped so the original quote isn't visually edited. - Cookbook serve: pre-launch GPU driver / has_gpu / install / version- floor checks (vllm minimax_m2 needs 0.10.0+, deepseek_r1 needs 0.7.0 etc.) before the launch chain starts. Detect 'another model already running on this host' and offer Stop & launch (with graceful then force tmux kill helpers, port release wait). Per-vendor deep-link buttons (vLLM recipe / SGLang cookbook) with hardware hash. Backend picker is now a custom dropdown with accent-coloured logos for vLLM, SGLang, llama.cpp, Ollama, Diffusers; same glyphs added next to package names in Dependencies. Runtime-readiness note moved inside the panel (green when ready, red when missing) with an × dismiss. Esc collapses the expanded card; expanded card scrolls when it overflows; Trust Remote / Auto Tool / Reasoning Parser / Enforce Eager / Prefix Caching / Expert Parallel / Speculative / MoE Env on one row (Reasoning Parser auto-detected per model family). Dtype→Row 1, GPUs→Row 2 (rightmost). Removed redundant GPU 'auto' input — command builders read from the GPU button strip. Default cookbook open is Download tab. - Cookbook hwfit: 'Model (latest)' / 'Model (oldest)' header sorts by release_date; release dates can be backfilled with the new scripts/backfill_model_release_dates.py and recipe metadata pulled with scripts/import_from_vllm_recipes.py against the upstream vllm-project/recipes catalog (vllm_recipe + min_vllm_version stamped on entries). - Calendar: Quick add hint cycles a random Odysseus-themed example per open (wooden horse Friday, crew muster 10am daily, council on Ithaca, …). Typing a time like '11pm' in the event title updates the hero clock live. - Doc editor: email-mode Reply button (sparkle icon, accent) opens the same Fast/Full + context popover the email reader uses; Ctrl+Alt+M toggles markdown preview. - Memories panel: custom sort picker with per-option icons, default 'Latest', visible Enabled/Disabled toggle text matching the section description style.
2026-06-15 20:47:51 +09:00
export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply', prefilledBody = '') {
2026-05-31 23:58:26 +09:00
if (!uid) return;
const previousFolder = _currentFolder;
_currentFolder = folder || 'INBOX';
try {
Open email context for agent, email search across All Mail, cookbook serve polish - Agent: pass the open email reader (uid/folder/account/from/subject/body preview) on every chat submit so 'reply to this' / 'write email saying hi' route to ui_control open_email_reply with the right UID instead of inventing a new .md draft. Code-level enforcement (chat_routes strips create_document + send_email when active_email is set); cross-session active_doc_id is now trusted instead of being silently dropped. set_active_email/clear_active_email tool-layer helpers in tool_implementations. - ui_control open_email_reply: optional body argument so the agent can open-and-write in one call; envelope now forwards uid/folder/account/ body/panel through tool_output. Tool description sharpened and the parser rejects empty bodies on reply/reply-all (forces the agent to write rather than open an empty draft). - Email library: search now runs against [Gmail]/All Mail when the current folder is INBOX (archived emails surface). Whirlpool spinner + 'Searching…' placeholder while in flight. Each search result is stamped with its source folder so clicks open the right email instead of whatever shares its UID in INBOX. Search no longer re-applies the same text pill locally (which only checks subject/from/snippet, never body) so body-only matches don't get dropped after IMAP returns them. Initial inbox load bumped 100→500. - Email favorites: 'Favorite (pin to top)' / 'Unfavorite' in both the card menu and the open-reader more menu, backed by a new /api/email/flag/{uid}?on=true|false endpoint. Flagged emails always bubble to the top of the grid regardless of active sort. - AI reply in doc editor: never overwrites existing draft text or the quoted history. AI suggestion is prepended; AI-generated 'On … wrote:' re-quotes are stripped so the original quote isn't visually edited. - Cookbook serve: pre-launch GPU driver / has_gpu / install / version- floor checks (vllm minimax_m2 needs 0.10.0+, deepseek_r1 needs 0.7.0 etc.) before the launch chain starts. Detect 'another model already running on this host' and offer Stop & launch (with graceful then force tmux kill helpers, port release wait). Per-vendor deep-link buttons (vLLM recipe / SGLang cookbook) with hardware hash. Backend picker is now a custom dropdown with accent-coloured logos for vLLM, SGLang, llama.cpp, Ollama, Diffusers; same glyphs added next to package names in Dependencies. Runtime-readiness note moved inside the panel (green when ready, red when missing) with an × dismiss. Esc collapses the expanded card; expanded card scrolls when it overflows; Trust Remote / Auto Tool / Reasoning Parser / Enforce Eager / Prefix Caching / Expert Parallel / Speculative / MoE Env on one row (Reasoning Parser auto-detected per model family). Dtype→Row 1, GPUs→Row 2 (rightmost). Removed redundant GPU 'auto' input — command builders read from the GPU button strip. Default cookbook open is Download tab. - Cookbook hwfit: 'Model (latest)' / 'Model (oldest)' header sorts by release_date; release dates can be backfilled with the new scripts/backfill_model_release_dates.py and recipe metadata pulled with scripts/import_from_vllm_recipes.py against the upstream vllm-project/recipes catalog (vllm_recipe + min_vllm_version stamped on entries). - Calendar: Quick add hint cycles a random Odysseus-themed example per open (wooden horse Friday, crew muster 10am daily, council on Ithaca, …). Typing a time like '11pm' in the event title updates the hero clock live. - Doc editor: email-mode Reply button (sparkle icon, accent) opens the same Fast/Full + context popover the email reader uses; Ctrl+Alt+M toggles markdown preview. - Memories panel: custom sort picker with per-option icons, default 'Latest', visible Enabled/Disabled toggle text matching the section description style.
2026-06-15 20:47:51 +09:00
await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply', '', prefilledBody || '');
2026-05-31 23:58:26 +09:00
} finally {
_currentFolder = previousFolder || _currentFolder;
}
}
2026-06-27 13:05:44 +00:00
function _bringEmailReplyDraftToFrontOnMobile() {
if (window.innerWidth > 768) return;
document.body.classList.remove('email-front', 'email-doc-split-active');
document.documentElement.style.removeProperty('--email-doc-split-left-x');
document.documentElement.style.removeProperty('--email-doc-split-email-w');
document.documentElement.style.removeProperty('--email-doc-split-right-x');
// Keep the email sheet visible behind the reply document on mobile. The
// document panel sits above it via the normal doc-view z-index rules, and
// swiping the document down minimizes it to a chip to reveal the email.
document.querySelectorAll('#email-lib-modal, .modal[id^="email-reader-"]').forEach(modal => {
modal.classList.remove('email-snap-left', 'modal-left-docked', 'modal-right-docked');
modal.style.removeProperty('z-index');
});
const docPane = document.getElementById('doc-editor-pane');
if (docPane) docPane.style.setProperty('z-index', '10010', 'important');
}
2026-05-31 23:58:26 +09:00
// When the document editor pane opens (body.doc-view turns on), make sure the
// email modal is on the LEFT — even if it was previously docked RIGHT or
// floating — so the email and the doc always end up side-by-side. The actual
// width math lives in modalSnap.js (`_anchorLeftDock` shrinks the email when
// the doc is rendered to the right).
let _docOpenObs = null;
function _watchDocOpenToReDockEmail() {
if (_docOpenObs) return;
if (typeof MutationObserver === 'undefined') return;
let last = document.body.classList.contains('doc-view');
_docOpenObs = new MutationObserver(() => {
const cur = document.body.classList.contains('doc-view');
if (cur && !last) {
if (window.innerWidth > 768) {
const emailModal = document.getElementById('email-lib-modal');
if (emailModal && !emailModal.classList.contains('hidden')) {
// Already left-docked → nothing to do (modalSnap re-anchors on its own).
if (!emailModal.classList.contains('modal-left-docked')) {
try { applyEdgeDock(emailModal, 'left'); } catch (_) {}
}
}
// Same treatment for an open email-reader modal (one specific email
// open standalone — typical "click email, click doc" flow).
document.querySelectorAll('.modal[id^="email-reader-"]').forEach(m => {
if (m.classList.contains('hidden')) return;
if (m.classList.contains('modal-left-docked')) return;
try { applyEdgeDock(m, 'left'); } catch (_) {}
});
}
}
last = cur;
});
_docOpenObs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
}
function _bindEvents() {
// Clicking anywhere in the email section header opens the popup
// (except the compose button which has its own handler)
const section = document.getElementById('email-section');
const header = section?.querySelector('.section-header-flex');
if (header) {
header.style.cursor = 'pointer';
header.addEventListener('click', (e) => {
if (e.target.closest('#email-compose-btn')) return;
openEmailLibrary();
markInboxAsSeen();
});
}
// Compose button creates a new email document
const composeBtn = document.getElementById('email-compose-btn');
if (composeBtn) {
composeBtn.addEventListener('click', (e) => {
e.stopPropagation();
_composeNew();
});
}
2026-06-30 01:28:08 +00:00
// Delay the lightweight unread badge check so opening Odysseus doesn't
// compete with the initial chat/session paint. The full email list now loads
// only when the inbox is actually opened.
setTimeout(_refreshUnreadCount, 8000);
2026-05-31 23:58:26 +09:00
setInterval(_refreshUnreadCount, 60000);
// Deep-link: #email=<folder>:<uid> opens the library and expands that card
_maybeOpenFromHash();
window.addEventListener('hashchange', _maybeOpenFromHash);
}
function _maybeOpenFromHash() {
const h = window.location.hash || '';
const m = h.match(/^#email=([^:]+):(\d+)/);
if (!m) return;
const folder = decodeURIComponent(m[1]);
const uid = m[2];
try { openEmailLibrary({ folder, uid }); } catch (e) { console.error(e); }
// Clear the hash so reloads don't reopen
try { history.replaceState(null, '', window.location.pathname + window.location.search); } catch (_) {}
}
// Tint helper — turns the urgent-email-scanner's max_score into a dot color.
// Falls back to the default (blue / unset) when scanner is off or no urgent.
function _urgencyColor(score) {
if (score >= 3) return 'var(--color-error, #e06c75)'; // red — urgent now
if (score === 2) return '#f0ad4e'; // orange — reply soon
return ''; // default (blue / theme)
}
async function _refreshUnreadCount() {
// Default the dot to hidden — only the verified "new mail above threshold"
// path below should turn it on. Without this, a fetch error or a backend
// returning malformed data left a stale dot from a previous account/session.
const dot = document.getElementById('email-unread-dot');
if (dot && !dot._stickyState) dot.style.display = 'none';
try {
2026-06-27 13:05:44 +00:00
// Parallel: cheap unread state + urgency state.
const [stateRes, urgRes] = await Promise.all([
fetch(emailApiUrl('/api/email/unread-state', { folder: 'INBOX' })),
2026-05-31 23:58:26 +09:00
fetch(`${API_BASE}/api/email/urgency-state`, { credentials: 'same-origin' }).catch(() => null),
]);
2026-06-27 13:05:44 +00:00
if (!stateRes || !stateRes.ok) return;
const data = await stateRes.json();
2026-05-31 23:58:26 +09:00
if (!dot) return;
2026-06-27 13:05:44 +00:00
const unreadCount = Number(data.unread_count || 0);
if (unreadCount <= 0) {
2026-05-31 23:58:26 +09:00
dot.style.display = 'none';
return;
}
// Compare highest unread UID to the last-seen threshold in localStorage
const lastSeen = parseInt(localStorage.getItem('odysseus-email-last-seen-uid') || '0', 10);
2026-06-27 13:05:44 +00:00
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
2026-05-31 23:58:26 +09:00
// Only show dot if there's a new email above the threshold
dot.style.display = maxUid > lastSeen ? '' : 'none';
// Color the dot by urgency tier. Cache the per-uid map so the per-row
// renderer can reuse it without a second fetch.
if (dot.style.display !== 'none' && urgRes && urgRes.ok) {
try {
const ud = await urgRes.json();
window._emailUrgencyState = ud;
const tint = _urgencyColor(ud.max_score || 0);
if (tint) dot.style.backgroundColor = tint;
else dot.style.backgroundColor = '';
} catch (_) {}
} else if (dot.style.display !== 'none') {
dot.style.backgroundColor = '';
}
} catch (e) {
// Network/parse error — keep the dot hidden (default at the top).
if (dot) dot.style.display = 'none';
}
}
export function markInboxAsSeen() {
// Called when the user opens the inbox popup — clears the notif dot
try {
// Find current max UID so subsequent arrivals trigger the dot
2026-06-27 13:05:44 +00:00
fetch(emailApiUrl('/api/email/unread-state', { folder: 'INBOX' }))
2026-05-31 23:58:26 +09:00
.then(r => r.json())
.then(data => {
2026-06-27 13:05:44 +00:00
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
if (maxUid > 0) {
2026-05-31 23:58:26 +09:00
localStorage.setItem('odysseus-email-last-seen-uid', String(maxUid));
}
const dot = document.getElementById('email-unread-dot');
if (dot) dot.style.display = 'none';
})
.catch(() => {});
} catch (e) {}
}
export async function loadEmails(append = false) {
if (_loading) return;
_loading = true;
const list = document.getElementById('email-list');
if (!list) { _loading = false; return; }
if (!append) {
list.innerHTML = '';
// Show whirlpool spinner
if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
const sp = spinnerModule.createWhirlpool(20);
_listSpinner = sp;
list.appendChild(sp.element);
}
try {
const fromQS = _senderFilter ? `&from=${encodeURIComponent(_senderFilter)}` : '';
2026-06-27 13:05:44 +00:00
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}`);
2026-05-31 23:58:26 +09:00
const data = await res.json();
if (data.error) throw new Error(data.error);
if (!append) _emails = [];
_emails.push(...(data.emails || []));
_total = data.total || 0;
// Remove spinner
if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
_renderList();
const unreadCount = _emails.filter(e => !e.is_read).length;
const dot = document.getElementById('email-unread-dot');
if (dot) dot.style.display = unreadCount > 0 ? '' : 'none';
} catch (e) {
console.error('Failed to load emails:', e);
if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
if (!append && list) {
const msg = e && e.message ? `Failed to load: ${e.message}` : 'Failed to load';
list.innerHTML = `<div class="email-loading">${msg.replace(/&/g, '&amp;').replace(/</g, '&lt;')}${_emailSetupHint()}</div>`;
}
} finally {
_loading = false;
}
}
async function loadFolders() {
try {
2026-06-27 13:05:44 +00:00
const accountQS = _acct().replace(/^&/, '');
const res = await fetch(`${API_BASE}/api/email/folders${accountQS ? `?${accountQS}` : ''}`);
2026-05-31 23:58:26 +09:00
const data = await res.json();
const select = document.getElementById('email-folder-select');
if (!select || !data.folders) return;
_populateFolderSelect(select, data.folders);
} catch (e) {
console.error('Failed to load folders:', e);
}
}
export function sortedFolders(folders) {
const roleOf = (folder) => {
const f = String(folder || '').toLowerCase();
if (f === 'inbox') return 'inbox';
if (f.includes('sent')) return 'sent';
if (f.includes('starred') || f.includes('flagged')) return 'starred';
if (f.includes('draft')) return 'drafts';
if (f.includes('all mail') || f.includes('archive')) return 'archive';
if (f.includes('spam') || f.includes('junk')) return 'junk';
if (f.includes('trash') || f.includes('bin') || f.includes('deleted')) return 'trash';
return '';
};
const roleOrder = ['inbox', 'sent', 'starred', 'archive', 'junk', 'trash', 'drafts'];
const found = new Map();
const others = [];
for (const f of folders) {
const role = roleOf(f);
if (role && !found.has(role)) found.set(role, f);
else others.push(f);
}
return { priority: roleOrder.map(role => found.get(role)).filter(Boolean), others };
}
export function folderDisplayName(folder) {
const raw = String(folder || '');
const f = raw.toLowerCase();
if (f === 'inbox') return 'INBOX';
if (f.includes('all mail')) return 'Archive / All Mail';
if (f.includes('archive')) return 'Archive';
if (f.includes('spam')) return 'Spam';
if (f.includes('junk')) return 'Junk';
if (f.includes('trash') || f.includes('bin') || f.includes('deleted')) return 'Trash';
if (f.includes('sent')) return 'Sent';
if (f.includes('draft')) return 'Drafts';
return raw;
}
function _populateFolderSelect(select, folders) {
select.innerHTML = '';
const { priority, others } = sortedFolders(folders);
for (const folder of priority) {
const opt = document.createElement('option');
opt.value = folder;
opt.textContent = folderDisplayName(folder);
if (folder === _currentFolder) opt.selected = true;
select.appendChild(opt);
}
if (priority.length > 0 && others.length > 0) {
const sep = document.createElement('option');
sep.disabled = true;
sep.textContent = '─────────';
select.appendChild(sep);
}
for (const folder of others) {
const opt = document.createElement('option');
opt.value = folder;
opt.textContent = folderDisplayName(folder);
if (folder === _currentFolder) opt.selected = true;
select.appendChild(opt);
}
}
function _renderList() {
const list = document.getElementById('email-list');
if (!list) return;
list.innerHTML = '';
if (_senderFilter) {
const chip = document.createElement('div');
chip.className = 'email-filter-chip';
chip.innerHTML = `<span class="email-filter-chip-label">From: ${_esc(_senderFilterLabel || _senderFilter)}</span><button class="email-filter-chip-clear" title="Clear filter">&times;</button>`;
chip.querySelector('.email-filter-chip-clear').addEventListener('click', () => _clearSenderFilter());
list.appendChild(chip);
}
if (_emails.length === 0) {
const empty = document.createElement('div');
empty.className = 'email-loading';
empty.textContent = _senderFilter ? `No emails from ${_senderFilterLabel || _senderFilter}` : 'No emails';
list.appendChild(empty);
return;
}
for (const em of _emails) {
list.appendChild(_createEmailItem(em));
}
const loadMore = document.getElementById('email-load-more');
if (loadMore) {
loadMore.style.display = (_emails.length < _total) ? '' : 'none';
}
}
function _setSenderFilter(addr, label) {
_senderFilter = addr;
_senderFilterLabel = label || addr;
_offset = 0;
loadEmails(false);
}
function _clearSenderFilter() {
_senderFilter = null;
_senderFilterLabel = null;
_offset = 0;
loadEmails(false);
}
function _createEmailItem(em) {
const item = document.createElement('div');
item.className = 'list-item email-item' + (em.is_spam_verdict ? ' email-item-spam' : '');
item.setAttribute('role', 'option');
item.setAttribute('data-uid', em.uid);
let dateStr = '';
if (em.date) {
try {
const d = new Date(em.date);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
dateStr = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
dateStr = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
} catch (_) {}
}
const senderName = em.from_name || em.from_address;
const initial = (senderName || '?')[0].toUpperCase();
const color = _senderColor(senderName);
const attachIcon = em.has_attachments
? '<span title="Has attachments" style="opacity:0.6;display:inline-flex;flex-shrink:0;margin-left:4px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span>'
: '';
// Per-row dot tint: if the urgency scanner flagged this UID, override the
// per-sender pastel with red (3) / orange (2). Look up by any cached key
// ending in `:<uid>` since the per_uid map is keyed `<account_id>:<uid>`
// and the inbox list doesn't surface the account id per row.
let _unreadColor = color;
let _unreadTitle = 'Unread';
try {
const us = window._emailUrgencyState;
if (us && us.per_uid && em.uid != null) {
const suffix = ':' + String(em.uid);
for (const k of Object.keys(us.per_uid)) {
if (k.endsWith(suffix)) {
const v = us.per_uid[k] || {};
const score = v.score || 0;
if (score >= 3) { _unreadColor = 'var(--color-error, #e06c75)'; _unreadTitle = 'Urgent — ' + (v.reason || 'needs reply now'); }
else if (score === 2) { _unreadColor = '#f0ad4e'; _unreadTitle = 'Reply soon — ' + (v.reason || ''); }
break;
}
}
}
} catch (_) {}
const unreadIcon = (!em.is_read && !em.is_answered)
? `<span class="email-unread-dot-inline" title="${_esc(_unreadTitle)}" style="display:inline-flex;align-items:center;flex-shrink:0;margin-left:4px;color:${_unreadColor}"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg></span>`
: '';
2026-06-27 13:05:44 +00:00
const tags = _showEmailTags ? _visibleEmailTagsForRender(em) : [];
const tagPills = _emailTagGroupHtml(tags, em);
2026-05-31 23:58:26 +09:00
2026-06-27 13:05:44 +00:00
const spamTag = _showEmailTags && em.is_spam_verdict
2026-05-31 23:58:26 +09:00
? `<span class="email-tag email-tag-spam" title="AI flagged as spam — click ✓ to unflag">spam <button class="email-spam-unflag" data-uid="${em.uid}" title="Not spam">\u2713</button></span>`
: '';
const senderAddr = (em.from_address || '').toLowerCase();
item.innerHTML = `
<span class="email-avatar" style="background:${color}">${initial}</span>
<div class="email-item-content">
<div class="email-item-top">
<span class="email-sender email-sender-clickable" style="color:${color}" data-from-addr="${_esc(senderAddr)}" data-from-name="${_esc(senderName)}" title="Show all emails from ${_esc(senderName)}">${_esc(senderName)}</span>
<span class="email-date">${_esc(dateStr)}</span>
</div>
<div class="email-subject">${_esc(em.subject)}${unreadIcon}${attachIcon}${tagPills}${spamTag}</div>
2026-05-31 23:58:26 +09:00
</div>
`;
// Click sender name → filter list to that sender
const senderEl = item.querySelector('.email-sender-clickable');
2026-06-27 13:05:44 +00:00
item.querySelectorAll('[data-calendar-event-uid]').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_openCalendarEventFromEmail(btn.dataset.calendarEventUid);
});
});
item.querySelectorAll('[data-email-filter-tag]').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_openEmailTagFilter(btn.dataset.emailFilterTag);
});
});
item.querySelectorAll('[data-email-tags-more]').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const wrap = btn.closest('.email-tags');
if (!wrap) return;
const expanded = wrap.classList.toggle('email-tags-expanded');
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
});
2026-05-31 23:58:26 +09:00
if (senderEl) {
senderEl.addEventListener('click', (e) => {
e.stopPropagation();
const addr = senderEl.dataset.fromAddr || '';
const name = senderEl.dataset.fromName || addr;
if (addr) _setSenderFilter(addr, name);
});
}
// Wire the "not spam" button
const unflagBtn = item.querySelector('.email-spam-unflag');
if (unflagBtn) {
unflagBtn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
await fetch(`${API_BASE}/api/email/${em.uid}/unflag-spam`, {
method: 'POST', credentials: 'same-origin',
});
em.is_spam_verdict = false;
item.classList.remove('email-item-spam');
const tag = item.querySelector('.email-tag-spam');
if (tag) tag.remove();
} catch (_) {}
});
}
// Click to open — do NOT close sidebar
item.addEventListener('click', (e) => {
if (item.dataset.swipeBlock === '1') return;
_openEmail(em, item);
});
// Swipe left to archive (mobile). Mirrors sidebar-layout.js swipe pattern.
if ('ontouchstart' in window) {
let startX = 0, startY = 0, dx = 0, dy = 0, swiping = false, swiped = false;
const HORIZ_THRESHOLD = 70; // px to trigger archive
const VERT_CANCEL = 30; // px vertical motion cancels swipe (treat as scroll)
item.addEventListener('touchstart', (e) => {
const t = e.touches[0];
startX = t.clientX; startY = t.clientY;
dx = 0; dy = 0; swiping = true; swiped = false;
item.style.transition = 'none';
}, { passive: true });
item.addEventListener('touchmove', (e) => {
if (!swiping) return;
const t = e.touches[0];
dx = t.clientX - startX;
dy = t.clientY - startY;
if (Math.abs(dy) > VERT_CANCEL) {
// Vertical scroll — cancel swipe
swiping = false;
item.style.transform = '';
return;
}
if (dx < 0) {
// Only swipe-left for archive; clamp at -160 so it doesn't fly off
const offset = Math.max(dx, -160);
item.style.transform = `translateX(${offset}px)`;
item.style.background = `linear-gradient(to right, transparent, transparent ${100 + offset/1.6}%, var(--red) ${100 + offset/1.6}%)`;
}
}, { passive: true });
item.addEventListener('touchend', () => {
if (!swiping) return;
swiping = false;
item.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
if (dx <= -HORIZ_THRESHOLD) {
// Trigger archive — animate off-screen, suppress next click
swiped = true;
item.dataset.swipeBlock = '1';
item.style.transform = 'translateX(-100%)';
item.style.opacity = '0';
setTimeout(() => {
_archiveEmail(em);
delete item.dataset.swipeBlock;
}, 200);
} else {
// Snap back
item.style.transform = '';
item.style.background = '';
}
});
item.addEventListener('touchcancel', () => {
swiping = false;
item.style.transition = 'transform 0.2s ease';
item.style.transform = '';
item.style.background = '';
});
}
return item;
}
Open email context for agent, email search across All Mail, cookbook serve polish - Agent: pass the open email reader (uid/folder/account/from/subject/body preview) on every chat submit so 'reply to this' / 'write email saying hi' route to ui_control open_email_reply with the right UID instead of inventing a new .md draft. Code-level enforcement (chat_routes strips create_document + send_email when active_email is set); cross-session active_doc_id is now trusted instead of being silently dropped. set_active_email/clear_active_email tool-layer helpers in tool_implementations. - ui_control open_email_reply: optional body argument so the agent can open-and-write in one call; envelope now forwards uid/folder/account/ body/panel through tool_output. Tool description sharpened and the parser rejects empty bodies on reply/reply-all (forces the agent to write rather than open an empty draft). - Email library: search now runs against [Gmail]/All Mail when the current folder is INBOX (archived emails surface). Whirlpool spinner + 'Searching…' placeholder while in flight. Each search result is stamped with its source folder so clicks open the right email instead of whatever shares its UID in INBOX. Search no longer re-applies the same text pill locally (which only checks subject/from/snippet, never body) so body-only matches don't get dropped after IMAP returns them. Initial inbox load bumped 100→500. - Email favorites: 'Favorite (pin to top)' / 'Unfavorite' in both the card menu and the open-reader more menu, backed by a new /api/email/flag/{uid}?on=true|false endpoint. Flagged emails always bubble to the top of the grid regardless of active sort. - AI reply in doc editor: never overwrites existing draft text or the quoted history. AI suggestion is prepended; AI-generated 'On … wrote:' re-quotes are stripped so the original quote isn't visually edited. - Cookbook serve: pre-launch GPU driver / has_gpu / install / version- floor checks (vllm minimax_m2 needs 0.10.0+, deepseek_r1 needs 0.7.0 etc.) before the launch chain starts. Detect 'another model already running on this host' and offer Stop & launch (with graceful then force tmux kill helpers, port release wait). Per-vendor deep-link buttons (vLLM recipe / SGLang cookbook) with hardware hash. Backend picker is now a custom dropdown with accent-coloured logos for vLLM, SGLang, llama.cpp, Ollama, Diffusers; same glyphs added next to package names in Dependencies. Runtime-readiness note moved inside the panel (green when ready, red when missing) with an × dismiss. Esc collapses the expanded card; expanded card scrolls when it overflows; Trust Remote / Auto Tool / Reasoning Parser / Enforce Eager / Prefix Caching / Expert Parallel / Speculative / MoE Env on one row (Reasoning Parser auto-detected per model family). Dtype→Row 1, GPUs→Row 2 (rightmost). Removed redundant GPU 'auto' input — command builders read from the GPU button strip. Default cookbook open is Download tab. - Cookbook hwfit: 'Model (latest)' / 'Model (oldest)' header sorts by release_date; release dates can be backfilled with the new scripts/backfill_model_release_dates.py and recipe metadata pulled with scripts/import_from_vllm_recipes.py against the upstream vllm-project/recipes catalog (vllm_recipe + min_vllm_version stamped on entries). - Calendar: Quick add hint cycles a random Odysseus-themed example per open (wooden horse Friday, crew muster 10am daily, council on Ithaca, …). Typing a time like '11pm' in the event title updates the hero clock live. - Doc editor: email-mode Reply button (sparkle icon, accent) opens the same Fast/Full + context popover the email reader uses; Ctrl+Alt+M toggles markdown preview. - Memories panel: custom sort picker with per-option icons, default 'Latest', visible Enabled/Disabled toggle text matching the section description style.
2026-06-15 20:47:51 +09:00
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
2026-06-01 23:02:25 +09:00
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : (mode === 'ai-reply-full' ? 'full' : '');
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
Open email context for agent, email search across All Mail, cookbook serve polish - Agent: pass the open email reader (uid/folder/account/from/subject/body preview) on every chat submit so 'reply to this' / 'write email saying hi' route to ui_control open_email_reply with the right UID instead of inventing a new .md draft. Code-level enforcement (chat_routes strips create_document + send_email when active_email is set); cross-session active_doc_id is now trusted instead of being silently dropped. set_active_email/clear_active_email tool-layer helpers in tool_implementations. - ui_control open_email_reply: optional body argument so the agent can open-and-write in one call; envelope now forwards uid/folder/account/ body/panel through tool_output. Tool description sharpened and the parser rejects empty bodies on reply/reply-all (forces the agent to write rather than open an empty draft). - Email library: search now runs against [Gmail]/All Mail when the current folder is INBOX (archived emails surface). Whirlpool spinner + 'Searching…' placeholder while in flight. Each search result is stamped with its source folder so clicks open the right email instead of whatever shares its UID in INBOX. Search no longer re-applies the same text pill locally (which only checks subject/from/snippet, never body) so body-only matches don't get dropped after IMAP returns them. Initial inbox load bumped 100→500. - Email favorites: 'Favorite (pin to top)' / 'Unfavorite' in both the card menu and the open-reader more menu, backed by a new /api/email/flag/{uid}?on=true|false endpoint. Flagged emails always bubble to the top of the grid regardless of active sort. - AI reply in doc editor: never overwrites existing draft text or the quoted history. AI suggestion is prepended; AI-generated 'On … wrote:' re-quotes are stripped so the original quote isn't visually edited. - Cookbook serve: pre-launch GPU driver / has_gpu / install / version- floor checks (vllm minimax_m2 needs 0.10.0+, deepseek_r1 needs 0.7.0 etc.) before the launch chain starts. Detect 'another model already running on this host' and offer Stop & launch (with graceful then force tmux kill helpers, port release wait). Per-vendor deep-link buttons (vLLM recipe / SGLang cookbook) with hardware hash. Backend picker is now a custom dropdown with accent-coloured logos for vLLM, SGLang, llama.cpp, Ollama, Diffusers; same glyphs added next to package names in Dependencies. Runtime-readiness note moved inside the panel (green when ready, red when missing) with an × dismiss. Esc collapses the expanded card; expanded card scrolls when it overflows; Trust Remote / Auto Tool / Reasoning Parser / Enforce Eager / Prefix Caching / Expert Parallel / Speculative / MoE Env on one row (Reasoning Parser auto-detected per model family). Dtype→Row 1, GPUs→Row 2 (rightmost). Removed redundant GPU 'auto' input — command builders read from the GPU button strip. Default cookbook open is Download tab. - Cookbook hwfit: 'Model (latest)' / 'Model (oldest)' header sorts by release_date; release dates can be backfilled with the new scripts/backfill_model_release_dates.py and recipe metadata pulled with scripts/import_from_vllm_recipes.py against the upstream vllm-project/recipes catalog (vllm_recipe + min_vllm_version stamped on entries). - Calendar: Quick add hint cycles a random Odysseus-themed example per open (wooden horse Friday, crew muster 10am daily, council on Ithaca, …). Typing a time like '11pm' in the event title updates the hero clock live. - Doc editor: email-mode Reply button (sparkle icon, accent) opens the same Fast/Full + context popover the email reader uses; Ctrl+Alt+M toggles markdown preview. - Memories panel: custom sort picker with per-option icons, default 'Latest', visible Enabled/Disabled toggle text matching the section description style.
2026-06-15 20:47:51 +09:00
// Body pre-fill from the agent's open_email_reply tool call takes the
// same insertion slot as an AI-suggested body — both land just before
// the quoted-original block.
let aiSuggestedBody = (typeof prefilledBody === 'string' && prefilledBody.trim()) ? prefilledBody.trim() : null;
2026-06-01 20:56:11 +09:00
if (wantsAiReply) {
2026-05-31 23:58:26 +09:00
// 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
// no one else to CC.
mode = 'reply-all';
}
// Show whirlpool spinner on the right side of the item (only if from sidebar)
let spinner = null;
if (itemEl) {
const sp = spinnerModule.createWhirlpool(16);
spinner = sp;
sp.element.style.cssText = 'margin:0;flex-shrink:0;';
const menuWrap = itemEl.querySelector('.email-menu-wrap');
if (menuWrap) menuWrap.style.display = 'none';
itemEl.appendChild(sp.element);
}
try {
let data = preloadedData;
if (!data) {
2026-06-27 13:05:44 +00:00
const fullQS = mode === 'forward' ? '&full=1' : '';
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}${fullQS}`);
2026-05-31 23:58:26 +09:00
data = await res.json();
}
if (data.error) {
console.error('Failed to read email:', data.error);
return;
}
2026-06-01 20:56:11 +09:00
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,
2026-06-01 23:02:25 +09:00
fast: aiReplyMode ? aiReplyMode === 'fast' : _shouldUseFastAiReply(data),
user_hint: (noteHint || '').trim() || undefined,
2026-06-01 20:56:11 +09:00
}),
});
const result = await res.json();
if (draftToastTimer) clearTimeout(draftToastTimer);
if (result.success && result.reply) {
aiSuggestedBody = _cleanAiReplyText(result.reply);
} else {
2026-06-29 10:07:48 +00:00
const _rawMsg = result.error || 'AI reply could not be generated';
const _msg = /empty response/i.test(_rawMsg)
? 'AI returned empty response.'
: _rawMsg;
2026-06-01 20:56:11 +09:00
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;
}
}
}
2026-05-31 23:58:26 +09:00
em.is_read = true;
if (itemEl) itemEl.classList.remove('email-unread');
// Addresses to exclude from Reply All. Prefer the full set of configured
// accounts (so a multi-account user's other mailboxes are excluded too),
// falling back to the single active address. Empty ⇒ no exclusion.
const myAddresses = (Array.isArray(window._myEmailAddresses) && window._myEmailAddresses.length)
? window._myEmailAddresses
: (window._myEmailAddress ? [window._myEmailAddress] : []);
2026-05-31 23:58:26 +09:00
2026-07-07 00:50:07 +00:00
const fromIsMe = _isMyEmailAddress(data.from_address, myAddresses);
const originalToWithoutMe = _withoutMyAddresses(data.to, myAddresses);
const originalCcWithoutMe = _withoutMyAddresses(data.cc, myAddresses);
let toAddress = fromIsMe
? (originalToWithoutMe.join(', ') || originalCcWithoutMe[0] || data.from_address)
: data.from_address;
2026-05-31 23:58:26 +09:00
let ccAddresses = '';
let subjectPrefix = 'Re: ';
if (mode === 'reply-all') {
2026-07-07 00:50:07 +00:00
if (fromIsMe) {
// Replying from Sent should go back to the people I originally wrote
// to, not to myself. Keep original Cc recipients on Cc.
toAddress = originalToWithoutMe.join(', ') || originalCcWithoutMe[0] || data.from_address;
ccAddresses = originalCcWithoutMe.filter(addr => !originalToWithoutMe.some(t => extractEmail(t) === extractEmail(addr))).join(', ');
} else {
// Build reply-all: TO = original sender, CC = everyone else (To + Cc minus me)
ccAddresses = buildReplyAllCc(data, myAddresses);
}
2026-05-31 23:58:26 +09:00
} else if (mode === 'forward') {
toAddress = '';
subjectPrefix = 'Fwd: ';
}
// Don't double-prefix `Re:` / `Fwd:` when the subject already starts with one.
// Replies to replies were producing `Re: Re: Re: …` which can also break
// some IMAP servers' header parsing on very long subject lines.
let _baseSubject = (data.subject || '').trim();
if (subjectPrefix === 'Re: ' && /^re\s*:/i.test(_baseSubject)) subjectPrefix = '';
else if (subjectPrefix === 'Fwd: ' && /^fwd?\s*:/i.test(_baseSubject)) subjectPrefix = '';
let content = `To: ${toAddress}\nSubject: ${subjectPrefix}${_baseSubject}`;
if (ccAddresses) content += `\nCc: ${ccAddresses}`;
if (mode !== 'forward' && data.message_id) content += `\nIn-Reply-To: ${data.message_id}`;
if (mode !== 'forward' && data.message_id) content += `\nReferences: ${data.references ? data.references + ' ' + data.message_id : data.message_id}`;
content += `\nX-Source-UID: ${em.uid}`;
content += `\nX-Source-Folder: ${_currentFolder}`;
if (data.attachments && data.attachments.length > 0) {
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
content += `\nX-Attachments: ${attStr}`;
2026-06-27 13:05:44 +00:00
if (mode === 'forward') content += `\nX-Forward-Attachments: 1`;
2026-05-31 23:58:26 +09:00
}
content += '\n---\n';
// Format the original date in a human-readable way for the quote header
let niceDate = data.date || '';
try {
if (data.date) {
const d = new Date(data.date);
if (!isNaN(d.getTime())) {
niceDate = d.toLocaleString([], {
weekday: 'short', month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
}
} catch (_) {}
// Plain-text body, with HTML fallback stripped if no text part exists.
// Without this, an HTML-only email gives data.body === null/undefined
// and the reply doc opens empty (data.body.split throws).
let _origBody = (typeof data.body === 'string' && data.body.length) ? data.body : '';
if (!_origBody && typeof data.body_html === 'string' && data.body_html) {
_origBody = data.body_html
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
if (mode === 'forward') {
content += `\n\n---------- Forwarded message ----------\n`;
content += `From: ${data.from_name} <${data.from_address}>\n`;
content += `Date: ${niceDate}\n`;
content += `Subject: ${data.subject}\n`;
if (data.to) content += `To: ${data.to}\n`;
content += `\n${_origBody}`;
} else {
const quotedBody = _origBody.split('\n').map(l => '> ' + l).join('\n');
// Inject AI-suggested body if present. No leading newline — the header
// block already ends with "---\n", so the reply must start on the very
// first body line, not one row down.
if (aiSuggestedBody) {
content += `${aiSuggestedBody}\n\n`;
} else {
content += '\n\n';
}
2026-06-01 20:56:11 +09:00
content += `${_replySeparator}\nOn ${niceDate}, ${data.from_name} <${data.from_address}> wrote:\n${quotedBody}`;
2026-05-31 23:58:26 +09:00
}
if (_docModule) {
// Agent-provided reply text should land in the email draft the user
// already has open. Otherwise mobile users see the source email while the
// agent silently creates a second draft elsewhere.
2026-07-07 00:50:07 +00:00
const reuseExisting = mode !== 'forward';
2026-05-31 23:58:26 +09:00
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
? _docModule.findEmailDocId(em.uid, _currentFolder)
: null;
if (existingDocId) {
if (!_docModule.isPanelOpen()) _docModule.openPanel();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
await _docModule.loadDocument(existingDocId);
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
2026-07-07 00:50:07 +00:00
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: true });
}
2026-07-07 00:50:07 +00:00
_bringEmailReplyDraftToFrontOnMobile();
2026-05-31 23:58:26 +09:00
} else {
2026-07-07 00:50:07 +00:00
const activeSid = await _createEmailChat(data);
2026-05-31 23:58:26 +09:00
if (!activeSid) {
2026-07-07 00:50:07 +00:00
console.error('reply: could not obtain a session_id');
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
return;
2026-05-31 23:58:26 +09:00
}
const docRes = await fetch(`${API_BASE}/api/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
2026-07-07 00:50:07 +00:00
session_id: activeSid,
2026-05-31 23:58:26 +09:00
title: data.subject,
content: content,
language: 'email',
}),
});
if (!docRes.ok) {
const errText = await docRes.text();
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
// uiModule isn't statically imported here — use the dynamic
// import pattern the rest of this file uses. (Previously this
// referenced a bare `uiModule`, throwing a ReferenceError that
// the outer catch swallowed → reply silently did nothing.)
import('./ui.js').then(m => m.showError && m.showError('Failed to create reply draft (' + docRes.status + ')')).catch(() => {});
return;
}
const doc = await docRes.json();
if (doc.id) {
const wasOpen = _docModule.isPanelOpen();
if (!wasOpen) _docModule.openPanel();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// Use the doc dict from the POST directly — avoids a 404 race
// when the GET fires before the new row is visible to the read
// connection (or when caching is interfering). loadDocument's
// GET path can still be used as a fallback.
if (_docModule.injectFreshDoc) {
_docModule.injectFreshDoc(doc);
} else {
await _docModule.loadDocument(doc.id);
}
2026-06-27 13:05:44 +00:00
_bringEmailReplyDraftToFrontOnMobile();
2026-05-31 23:58:26 +09:00
}
}
}
} catch (e) {
console.error('Failed to open email:', e);
// Surface the failure so a silent throw in the reply flow doesn't
// look like "nothing happened". Dynamic import — uiModule isn't a
// static import in this file.
const msg = e && e.message ? e.message : String(e);
import('./ui.js').then(m => m.showError && m.showError('Reply failed: ' + msg)).catch(() => {});
} finally {
if (spinner) { spinner.destroy(); spinner.element.remove(); }
if (itemEl) {
const menuWrap = itemEl.querySelector('.email-menu-wrap');
if (menuWrap) menuWrap.style.display = '';
}
}
}
function _showEmailMenu(em, anchor, itemEl) {
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
2026-05-31 23:58:26 +09:00
const dropdown = document.createElement('div');
dropdown.className = 'dropdown email-dropdown show';
const actions = [
{ label: 'Open', icon: _replyIcon, action: () => _openEmail(em, itemEl) },
{ label: 'Remind to reply', icon: _bellIcon, submenu: 'remind' },
{ label: 'Archive', icon: _archiveIcon, action: () => _archiveEmail(em) },
{ label: 'Delete', icon: _deleteIcon, danger: true, action: () => _deleteEmail(em) },
];
for (const a of actions) {
const menuItem = document.createElement('div');
menuItem.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
const arrow = a.submenu ? ' <span style="margin-left:auto;opacity:0.5;">›</span>' : '';
menuItem.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${arrow}`;
menuItem.addEventListener('click', (e) => {
e.stopPropagation();
if (a.submenu === 'remind') {
_showRemindSubmenu(em, dropdown);
return;
}
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
close();
2026-05-31 23:58:26 +09:00
a.action();
});
dropdown.appendChild(menuItem);
}
anchor.appendChild(dropdown);
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
2026-05-31 23:58:26 +09:00
}
// ---- Reminder submenu (creates a Note with a reminder for this email) ----
function _showRemindSubmenu(em, parentDropdown) {
// Replace content of parent dropdown with time presets
parentDropdown.innerHTML = '';
const header = document.createElement('div');
header.className = 'dropdown-item-compact';
header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
header.innerHTML = '<span>Remind me</span>';
parentDropdown.appendChild(header);
const now = new Date();
const laterToday = new Date(now);
const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0);
if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3 * 60 * 60 * 1000);
else laterToday.setTime(sixPm.getTime());
const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(8, 0, 0, 0);
const daysUntilMon = (8 - now.getDay()) % 7 || 7;
const nextWeek = new Date(now); nextWeek.setDate(now.getDate() + daysUntilMon); nextWeek.setHours(8, 0, 0, 0);
const presets = [
{ label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), date: laterToday },
{ label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), date: tomorrow },
{ label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday: 'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), date: nextWeek },
];
for (const p of presets) {
const item = document.createElement('div');
item.className = 'dropdown-item-compact';
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
item.addEventListener('click', async (e) => {
e.stopPropagation();
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
dismissOrRemove(parentDropdown);
2026-05-31 23:58:26 +09:00
await _createReplyReminder(em, p.date);
});
parentDropdown.appendChild(item);
}
const customItem = document.createElement('div');
customItem.className = 'dropdown-item-compact';
customItem.innerHTML = '<span>Pick date and time…</span>';
customItem.addEventListener('click', async (e) => {
e.stopPropagation();
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
dismissOrRemove(parentDropdown);
2026-05-31 23:58:26 +09:00
const tmp = document.createElement('input');
tmp.type = 'datetime-local';
const def = new Date(tomorrow);
const pad = n => String(n).padStart(2, '0');
tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`;
tmp.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:13px;';
document.body.appendChild(tmp);
tmp.focus();
if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} }
// Cleanup helper — also unwires the global listeners so they don't
// linger after dismiss.
const _cleanup = () => {
tmp.remove();
document.removeEventListener('keydown', _onKey);
document.removeEventListener('mousedown', _onDocClick, true);
};
const _onKey = (ev) => { if (ev.key === 'Escape') _cleanup(); };
// Click-outside dismiss. Replaces the old blur-based auto-remove —
// blur fires whenever the native datetime popup steals focus, so
// the input vanished before the user could click any date. Now we
// only dismiss when the user clicks something that is NOT the
// input itself (the native picker popup is a browser-owned overlay
// OUTSIDE the document, so its clicks don't fire here at all — no
// false dismissals).
const _onDocClick = (ev) => { if (ev.target !== tmp) _cleanup(); };
tmp.addEventListener('change', async () => {
if (tmp.value) {
await _createReplyReminder(em, new Date(tmp.value));
}
_cleanup();
});
document.addEventListener('keydown', _onKey);
// Defer the click-outside listener so the click that opened this
// input doesn't immediately close it.
setTimeout(() => document.addEventListener('mousedown', _onDocClick, true), 50);
});
parentDropdown.appendChild(customItem);
}
async function _createReplyReminder(em, dueDate) {
const pad = n => String(n).padStart(2, '0');
const iso = `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}`;
const from = em.from || em.sender || 'someone';
const payload = {
title: `Reply: ${em.subject || '(no subject)'}`,
content: `From: ${from}\n\nRemember to reply to this email.`,
note_type: 'note',
label: 'email',
due_date: iso,
source: 'email',
};
try {
const res = await fetch(`${API_BASE}/api/notes`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Failed');
const { showToast } = await import('./ui.js');
const fmt = dueDate.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
showToast(`Reminder set for ${fmt}`);
// Request notification permission if needed
if ('Notification' in window && Notification.permission === 'default') {
try { Notification.requestPermission(); } catch {}
}
} catch (e) {
const { showError } = await import('./ui.js');
showError('Failed to create reminder');
}
}
async function _archiveEmail(em) {
try {
await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' });
_emails = _emails.filter(e => e.uid !== em.uid);
_renderList();
} catch (e) {
console.error('Failed to archive:', e);
}
}
async function _deleteEmail(em) {
const subject = em.subject || '(no subject)';
const { styledConfirm } = await import('./ui.js');
const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true });
if (!ok) return;
2026-06-28 22:19:32 +00:00
const row = document.querySelector(`.email-item[data-uid="${CSS.escape(String(em.uid))}"]`);
const busy = _showEmailDeleteOverlay(row);
await busy?.ready;
2026-05-31 23:58:26 +09:00
try {
await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'DELETE' });
2026-06-28 22:19:32 +00:00
busy?.remove?.();
2026-05-31 23:58:26 +09:00
_emails = _emails.filter(e => e.uid !== em.uid);
_renderList();
} catch (e) {
2026-06-28 22:19:32 +00:00
busy?.remove?.();
2026-05-31 23:58:26 +09:00
console.error('Failed to delete:', e);
}
}
2026-06-28 22:19:32 +00:00
function _showEmailDeleteOverlay(target) {
if (!target) return null;
const wp = spinnerModule.createWhirlpool(16);
const overlay = document.createElement('div');
overlay.className = 'email-delete-overlay';
overlay.appendChild(wp.element);
const prevPos = target.style.position;
const prevPointerEvents = target.style.pointerEvents;
if (getComputedStyle(target).position === 'static') target.style.position = 'relative';
target.style.pointerEvents = 'none';
target.classList.add('email-delete-busy');
target.appendChild(overlay);
const ready = new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
2026-06-28 22:19:32 +00:00
return {
ready,
2026-06-28 22:19:32 +00:00
remove() {
try { wp.destroy?.(); } catch (_) {}
overlay.remove();
target.classList.remove('email-delete-busy');
target.style.pointerEvents = prevPointerEvents;
target.style.position = prevPos;
}
};
}
2026-05-31 23:58:26 +09:00
async function _toggleDone(em, itemEl) {
const newState = !em.is_answered;
em.is_answered = newState;
if (newState) em.is_read = true; // mark-done implies mark-read
if (itemEl) {
if (newState) {
2026-06-27 13:05:44 +00:00
_clearDoneResponseTagsLocal(em);
2026-05-31 23:58:26 +09:00
itemEl.classList.remove('email-unread');
// Also drop any inline unread indicator dots the renderer may have added
itemEl.querySelectorAll('.email-unread-dot, [data-unread-dot]').forEach(n => n.remove());
2026-06-27 13:05:44 +00:00
itemEl.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
2026-05-31 23:58:26 +09:00
}
const check = itemEl.querySelector('.email-done-check');
if (check) check.classList.toggle('active', newState);
}
try {
if (newState) {
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' });
} else {
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' });
}
} catch (e) {
console.error('Failed to toggle done:', e);
}
}
async function _createEmailChat(emailData) {
2026-07-07 00:50:07 +00:00
const subject = String(emailData?.subject || 'New Email').trim() || 'New Email';
const title = subject === 'New Email' ? 'New Email' : `Email: ${subject.slice(0, 60)}`;
2026-05-31 23:58:26 +09:00
try {
2026-07-07 00:50:07 +00:00
const currentSid = sessionModule.getCurrentSessionId?.() || '';
const current = sessionModule.getSessions?.().find(s => s.id === currentSid);
const currentIsBlank = !!current
&& !current.archived
&& !current.has_documents
&& !current.has_images
&& Number(current.message_count || 0) === 0
&& current.folder !== 'Assistant'
&& current.folder !== 'Tasks';
if (currentIsBlank) {
const meta = document.getElementById('current-meta');
if (meta) meta.textContent = title;
return current.id;
}
let url = current?.endpoint_url || '';
let model = current?.model || '';
let endpointId = current?.endpoint_id || '';
if (!url || !model) {
try {
const dcRes = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
const dc = dcRes.ok ? await dcRes.json() : {};
url = dc.endpoint_url || '';
model = dc.model || '';
endpointId = dc.endpoint_id || '';
} catch (_) {}
2026-05-31 23:58:26 +09:00
}
2026-07-07 00:50:07 +00:00
const fd = new FormData();
fd.append('name', title);
fd.append('skip_validation', 'true');
if (url) fd.append('endpoint_url', url);
if (model) fd.append('model', model);
if (endpointId) fd.append('endpoint_id', endpointId);
const res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd, credentials: 'same-origin' });
if (!res.ok) {
console.error('email chat create failed', res.status, await res.text().catch(() => ''));
return '';
2026-05-31 23:58:26 +09:00
}
2026-07-07 00:50:07 +00:00
const payload = await res.json().catch(() => ({}));
const sid = payload?.id || '';
if (!sid) return '';
if (sessionModule?.loadSessions) await sessionModule.loadSessions();
if (sessionModule?.selectSession) await sessionModule.selectSession(sid);
const meta = document.getElementById('current-meta');
if (meta) meta.textContent = title;
return sid;
2026-05-31 23:58:26 +09:00
} catch (e) {
console.error('Failed to create email chat:', e);
2026-07-07 00:50:07 +00:00
return '';
2026-05-31 23:58:26 +09:00
}
}
async function _composeNew() {
if (!_docModule) return;
// NOTE: don't open the panel here. Creating the email-scoped chat below can
// switch sessions, which tears the panel down — so an early open would mount
// the pane, get closed, then injectFreshDoc remounts it: a visible flash
// (doc shows for a frame, then slides up again). Mount once, at injectFreshDoc,
// after the session + doc exist.
try {
2026-07-07 00:50:07 +00:00
const sid = await _createEmailChat({ subject: 'New Email' });
2026-05-31 23:58:26 +09:00
if (!sid) {
console.error('compose: could not obtain a session_id');
import('./ui.js').then(m => m.showError && m.showError('Could not start a new email (no session).')).catch(() => {});
return;
}
const res = await fetch(`${API_BASE}/api/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sid,
title: 'New Email',
content: 'To: \nSubject: \n---\n',
language: 'email',
}),
});
if (!res.ok) {
console.error('compose POST failed', res.status, await res.text().catch(() => ''));
import('./ui.js').then(m => m.showError && m.showError('Failed to create new email (' + res.status + ')')).catch(() => {});
return;
}
const doc = await res.json();
if (doc.id) {
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// Use the doc dict from POST directly to avoid the GET 404 race that
// hits a freshly-created doc on a separate read connection.
if (_docModule.injectFreshDoc) {
_docModule.injectFreshDoc(doc);
} else {
_docModule.loadDocument(doc.id);
}
}
} catch (e) {
console.error('Failed to create email:', e);
}
}
function _esc(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function _senderColor(name) {
if (!name) return 'hsl(220, 55%, 65%)';
const key = name.toLowerCase();
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0;
}
const hue = ((hash % 360) + 360) % 360;
return `hsl(${hue}, 55%, 65%)`;
}