mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
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.
This commit is contained in:
+47
-1
@@ -630,6 +630,28 @@ function _getModal() {
|
||||
|
||||
// ── Render dispatch ──
|
||||
|
||||
// Quick-add hint examples — the placeholder cycles through these every few
|
||||
// seconds so users see different prompt shapes (events, deadlines, recurring).
|
||||
const _QA_HINT_EXAMPLES = [
|
||||
'return home to Ithaca 1pm tmrw',
|
||||
'dinner with Penelope Friday 8pm',
|
||||
'coffee with Athena 9am Saturday',
|
||||
'call Telemachus tomorrow morning',
|
||||
'dentist appointment 3pm next Tuesday',
|
||||
'finish the wooden horse by Friday EOD',
|
||||
'gym 7am every weekday',
|
||||
'flight to Athens Sunday 6:30am',
|
||||
'crew muster 10am daily',
|
||||
'council on Ithaca Monday 2pm',
|
||||
];
|
||||
function _initQuickAddHintCycle() {
|
||||
const span = document.getElementById('qa-hint-example');
|
||||
if (!span) return;
|
||||
// Pick one random example per calendar open — no interval cycling.
|
||||
const idx = Math.floor(Math.random() * _QA_HINT_EXAMPLES.length);
|
||||
span.textContent = _QA_HINT_EXAMPLES[idx];
|
||||
}
|
||||
|
||||
// Stash the quick-add input's state (focus + caret + value) before a
|
||||
// re-render so background fetches don't kick the user out mid-type. Picked
|
||||
// up by _wireAll after the new DOM lands.
|
||||
@@ -844,7 +866,7 @@ function _headerHTML() {
|
||||
placeholder=" "
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="cal-quickadd-hint" id="cal-quickadd-hint" aria-hidden="true"><span class="qa-hint-accent">Quick add</span> — return home to Ithaca 1pm tmrw <svg class="qa-hint-enter" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
|
||||
<span class="cal-quickadd-hint" id="cal-quickadd-hint" aria-hidden="true"><span class="qa-hint-accent">Quick add</span> — <span class="qa-hint-example" id="qa-hint-example">return home to Ithaca 1pm tmrw</span> <svg class="qa-hint-enter" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
|
||||
<span class="cal-quickadd-status" id="cal-quickadd-status"></span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1911,6 +1933,7 @@ function _wireAll(body) {
|
||||
// ── Quick-add input ─────────────────────────────────────────────
|
||||
const _qaInput = document.getElementById('cal-quickadd');
|
||||
const _qaStatus = document.getElementById('cal-quickadd-status');
|
||||
_initQuickAddHintCycle();
|
||||
if (_qaInput && !_qaInput._wired) {
|
||||
_qaInput._wired = true;
|
||||
const _submitQA = async () => {
|
||||
@@ -3061,6 +3084,29 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
// mode opens already expanded when there's any detail content to see.
|
||||
titleInput?.addEventListener('focus', () => setExpanded(true), { once: true });
|
||||
|
||||
// Live time parse: typing a time like "11pm" or "15:30" into the title
|
||||
// updates the hero clock + start input on the fly. The same parser still
|
||||
// runs again on submit, but doing it live makes the hero clock track
|
||||
// intent immediately instead of jumping at save.
|
||||
if (titleInput) {
|
||||
titleInput.addEventListener('input', () => {
|
||||
if (document.getElementById('cal-f-allday')?.checked) return;
|
||||
const tt = _parseTitleTime(titleInput.value);
|
||||
if (!tt) return;
|
||||
const startEl = document.getElementById('cal-f-start');
|
||||
const endEl = document.getElementById('cal-f-end');
|
||||
const newStart = `${String(tt.h).padStart(2, '0')}:${String(tt.m).padStart(2, '0')}`;
|
||||
if (!startEl || startEl.value === newStart) return;
|
||||
const toMin = (v) => { const p = (v || '').split(':'); return p.length === 2 ? (+p[0]) * 60 + (+p[1]) : null; };
|
||||
const s0 = toMin(startEl.value), e0 = toMin(endEl?.value);
|
||||
const dur = (s0 != null && e0 != null && e0 > s0) ? e0 - s0 : 60;
|
||||
startEl.value = newStart;
|
||||
const endMin = (tt.h * 60 + tt.m + dur) % 1440;
|
||||
if (endEl) endEl.value = `${String(Math.floor(endMin / 60)).padStart(2, '0')}:${String(endMin % 60).padStart(2, '0')}`;
|
||||
startEl.dispatchEvent(new Event('input'));
|
||||
});
|
||||
}
|
||||
|
||||
// Location → Apple Maps. The pin button next to the input is enabled
|
||||
// only when there's a non-empty location, and its href tracks the live
|
||||
// input value. Apple's universal URL opens the native Maps app on
|
||||
|
||||
@@ -785,6 +785,19 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ }
|
||||
fd.append('active_doc_id', documentModule.getCurrentDocId());
|
||||
}
|
||||
// Active email context — when an email reader is open, pass its
|
||||
// uid/folder/account so "reply", "summarize", "what does this say"
|
||||
// resolve to the email the user is actually looking at instead of
|
||||
// making the agent invent a new markdown draft with fake headers.
|
||||
try {
|
||||
const getEmailCtx = window.__odysseusGetActiveEmailContext;
|
||||
const emCtx = typeof getEmailCtx === 'function' ? getEmailCtx() : null;
|
||||
if (emCtx && emCtx.uid) {
|
||||
fd.append('active_email_uid', String(emCtx.uid));
|
||||
fd.append('active_email_folder', String(emCtx.folder || 'INBOX'));
|
||||
if (emCtx.account) fd.append('active_email_account', String(emCtx.account));
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
// Web toggle: pre-search in Chat mode, tool permission in Agent mode
|
||||
const toggleState = Storage.loadToggleState();
|
||||
let isAgentMode = (toggleState.mode || 'chat') === 'agent';
|
||||
|
||||
@@ -185,7 +185,7 @@ export function handleUIControl(uiData) {
|
||||
} else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
|
||||
import('./emailInbox.js').then(function(mod) {
|
||||
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
|
||||
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply');
|
||||
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
|
||||
}).catch(function(e) {
|
||||
console.warn('open_email_reply failed:', e);
|
||||
});
|
||||
|
||||
+17
-1
@@ -792,6 +792,22 @@ async function _fetchDependencies() {
|
||||
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
|
||||
};
|
||||
|
||||
// Per-package inline glyphs — same accent-coloured marks used in the
|
||||
// Backend picker on the Run page, so the Dependencies row visually
|
||||
// matches the engine you're configuring. Unknown packages get no
|
||||
// icon (the name alone is fine for librosa, hf_transfer, etc.).
|
||||
const _DEP_GLYPHS = {
|
||||
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||
llama_cpp: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg>',
|
||||
ollama: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 10a6 6 0 0 1 12 0v4a4 4 0 0 1-8 0v-1"/><circle cx="10" cy="9" r="1"/><circle cx="14" cy="9" r="1"/></svg>',
|
||||
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
};
|
||||
const _depGlyphHtml = (name) => {
|
||||
const g = _DEP_GLYPHS[name];
|
||||
return g ? `<span class="cookbook-dep-glyph" aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));margin-right:5px;vertical-align:-2px;">${g}</span>` : '';
|
||||
};
|
||||
|
||||
const _depRow = (pkg) => {
|
||||
const isLocal = pkg.target === 'local';
|
||||
const isSystemDep = pkg.kind === 'system';
|
||||
@@ -821,7 +837,7 @@ async function _fetchDependencies() {
|
||||
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
|
||||
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
|
||||
+ `<div class="cookbook-dep-info">`
|
||||
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
|
||||
+ `<div class="memory-item-title">${_depGlyphHtml(pkg.name)}${esc(pkg.name)}</div>`
|
||||
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
|
||||
+ note
|
||||
+ updateNote
|
||||
|
||||
@@ -807,7 +807,7 @@ function _winSessionCmd(task, tmuxArgs) {
|
||||
return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
|
||||
}
|
||||
|
||||
function _tmuxGracefulKill(task) {
|
||||
export function _tmuxGracefulKill(task) {
|
||||
if (_isWindows(task)) {
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
@@ -824,6 +824,48 @@ function _tmuxGracefulKill(task) {
|
||||
return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`;
|
||||
}
|
||||
|
||||
// Force-kill escalation: SIGKILL the tmux pane's owning PID and any children,
|
||||
// then nuke the session. Use AFTER the graceful kill when the process is
|
||||
// still detected — vLLM sometimes ignores SIGINT during model init, and a
|
||||
// stuck CUDA context can survive `tmux kill-session` alone.
|
||||
export function _tmuxForceKill(task) {
|
||||
if (_isWindows(task)) {
|
||||
// Windows graceful path already does Stop-Process -Force, so the same
|
||||
// command serves as the "force" variant.
|
||||
return _tmuxGracefulKill(task);
|
||||
}
|
||||
const sid = task.sessionId;
|
||||
const inner =
|
||||
`PIDS=$(tmux list-panes -t ${sid} -F "#{pane_pid}" 2>/dev/null); ` +
|
||||
`if [ -n "$PIDS" ]; then ` +
|
||||
` for P in $PIDS; do ` +
|
||||
` pkill -KILL -P "$P" 2>/dev/null; ` +
|
||||
` kill -9 "$P" 2>/dev/null; ` +
|
||||
` done; ` +
|
||||
`fi; ` +
|
||||
`tmux kill-session -t ${sid} 2>/dev/null`;
|
||||
if (task.remoteHost) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`;
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
// Returns a shell snippet that prints "ALIVE" if the tmux session still
|
||||
// exists (or its main PID is still listed in /proc), "DEAD" otherwise.
|
||||
// Used by the Stop-all escalation to decide whether to force-kill.
|
||||
export function _tmuxIsAliveCheck(task) {
|
||||
if (_isWindows(task)) {
|
||||
// Skip the check on Windows — the graceful path already force-kills.
|
||||
return null;
|
||||
}
|
||||
const sid = task.sessionId;
|
||||
const inner = `if tmux has-session -t ${sid} 2>/dev/null; then echo ALIVE; else echo DEAD; fi`;
|
||||
if (task.remoteHost) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`;
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
function _shQuote(value) {
|
||||
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
@@ -1668,7 +1710,11 @@ export function _renderRunningTab() {
|
||||
group = document.createElement('div');
|
||||
group.className = 'cookbook-group hidden';
|
||||
group.dataset.backendGroup = 'Running';
|
||||
group.innerHTML = '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">' +
|
||||
// No `flex:1` on the card — with overflow:visible (forced via #cookbook-modal
|
||||
// .cookbook-group > .admin-card), flex:1 collapsed the card to body height
|
||||
// and the body's scrollHeight stopped tracking the overflowing children.
|
||||
// Sized-to-content means cookbook-body's overflow-y:auto kicks in naturally.
|
||||
group.innerHTML = '<div class="admin-card" style="display:flex;flex-direction:column;">' +
|
||||
'<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">' +
|
||||
'<h2 style="margin:0;padding:0;line-height:1;">Active <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + activeCount + '</span></h2>' +
|
||||
'</div>' +
|
||||
@@ -1761,9 +1807,21 @@ export function _renderRunningTab() {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation(); // don't toggle the section collapse (was an inline onclick, blocked by CSP)
|
||||
const host = btn.dataset.clearServer;
|
||||
if (!await window.styledConfirm(`Clear finished tasks on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
|
||||
const allTasks = _loadTasks();
|
||||
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && _canClearTask(t));
|
||||
// Bail with a clear message instead of silently doing nothing when
|
||||
// every task on this server is still running (nothing finished to
|
||||
// clear yet) — the previous behavior looked like the button was dead.
|
||||
if (!toRemove.length) {
|
||||
const stillRunning = allTasks.filter(t => (t.remoteHost || '') === host && t.status === 'running').length;
|
||||
const _msg = stillRunning
|
||||
? `No finished tasks on ${_serverName(host)} — ${stillRunning} still running. Stop them first to clear.`
|
||||
: `No finished tasks on ${_serverName(host)}.`;
|
||||
if (window.uiModule?.showToast) window.uiModule.showToast(_msg);
|
||||
else alert(_msg);
|
||||
return;
|
||||
}
|
||||
if (!await window.styledConfirm(`Clear ${toRemove.length} finished task${toRemove.length === 1 ? '' : 's'} on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
|
||||
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || !_canClearTask(t));
|
||||
_saveTasks(remaining);
|
||||
// Fade/slide each finished card out (same exit as the per-card clear)
|
||||
|
||||
+142
-17
@@ -2472,6 +2472,8 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
// Hide toolbar items that have no clean WYSIWYG equivalent in email (Code).
|
||||
document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = 'none'; });
|
||||
// Show email-only toolbar items (AI reply button).
|
||||
document.querySelectorAll('.md-toolbar-email-only').forEach(el => { el.style.display = 'inline-flex'; });
|
||||
if (emailHeader) emailHeader.style.display = '';
|
||||
if (emailActions) emailActions.style.display = '';
|
||||
// Emails have their own complete footer (Close / More / Send), so hide the
|
||||
@@ -2864,6 +2866,8 @@ import * as Modals from './modalManager.js';
|
||||
if (emailActions) emailActions.style.display = 'none';
|
||||
// Restore toolbar items that were hidden for email (Code dropdown).
|
||||
document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = ''; });
|
||||
// Re-hide email-only toolbar items (AI reply button).
|
||||
document.querySelectorAll('.md-toolbar-email-only').forEach(el => { el.style.display = 'none'; });
|
||||
// Restore the generic documents action bar + its bottom footer (Close /
|
||||
// Copy / Export) for non-email docs.
|
||||
const docActions = document.getElementById('doc-editor-actions');
|
||||
@@ -3206,7 +3210,95 @@ import * as Modals from './modalManager.js';
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
async function _aiReply() {
|
||||
// Fast/Full + optional context popover for the doc-editor email Reply button.
|
||||
// Mirrors the email reader's AI reply choice popover so the UX is identical:
|
||||
// textarea for an optional steering note, then Fast (lightning) or Full
|
||||
// (concentric dot) buttons; both feed into _aiReply with the chosen mode.
|
||||
let _docAiReplyChoiceMenu = null;
|
||||
function _closeDocAiReplyChoice() {
|
||||
if (_docAiReplyChoiceMenu) {
|
||||
try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
|
||||
_docAiReplyChoiceMenu = null;
|
||||
}
|
||||
}
|
||||
function _showDocAiReplyChoice(btn) {
|
||||
_closeDocAiReplyChoice();
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'doc-ai-reply-choice';
|
||||
const menuMaxW = Math.min(240, window.innerWidth - 16);
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8));
|
||||
const estHeight = 150;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||
const spaceAbove = rect.top - 8;
|
||||
const top = (spaceBelow >= estHeight || spaceBelow >= spaceAbove)
|
||||
? Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8))
|
||||
: Math.max(8, rect.top - estHeight - 6);
|
||||
menu.style.cssText = [
|
||||
'position:fixed',
|
||||
`left:${left}px`,
|
||||
`top:${top}px`,
|
||||
`max-width:${menuMaxW}px`,
|
||||
'box-sizing:border-box',
|
||||
'z-index:10060',
|
||||
'display:flex',
|
||||
'gap:6px',
|
||||
'padding:6px',
|
||||
'background:var(--bg,#111)',
|
||||
'border:1px solid var(--border,#333)',
|
||||
'border-radius:7px',
|
||||
'box-shadow:0 8px 24px rgba(0,0,0,.28)',
|
||||
].join(';');
|
||||
menu.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:6px;min-width:200px;">
|
||||
<textarea data-note-input rows="2" placeholder="Add context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Shorter, faster draft" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Fast
|
||||
</button>
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-full" title="Fuller reply with more context" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><circle cx="12" cy="12" r="6"/></svg>
|
||||
Full
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const noteInput = menu.querySelector('[data-note-input]');
|
||||
setTimeout(() => noteInput?.focus(), 0);
|
||||
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
|
||||
menu.addEventListener('click', async (ev) => {
|
||||
const choice = ev.target.closest('[data-mode]');
|
||||
if (!choice) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
|
||||
const noteHint = (noteInput?.value || '').trim();
|
||||
_closeDocAiReplyChoice();
|
||||
await _aiReply({ mode, noteHint });
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
_docAiReplyChoiceMenu = menu;
|
||||
const outsideClose = (ev) => {
|
||||
if (menu.contains(ev.target)) return;
|
||||
document.removeEventListener('click', outsideClose, true);
|
||||
_closeDocAiReplyChoice();
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
|
||||
// Esc to close.
|
||||
const escClose = (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.stopPropagation();
|
||||
document.removeEventListener('keydown', escClose, true);
|
||||
_closeDocAiReplyChoice();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escClose, true);
|
||||
}
|
||||
|
||||
async function _aiReply(opts = {}) {
|
||||
const { mode = 'auto', noteHint = '' } = (opts || {});
|
||||
const to = document.getElementById('doc-email-to')?.value?.trim() || '';
|
||||
const subject = document.getElementById('doc-email-subject')?.value?.trim() || '';
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
@@ -3251,32 +3343,43 @@ import * as Modals from './modalManager.js';
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Drafting...'; }
|
||||
|
||||
try {
|
||||
// Empty-compose path: if there's no original body, send a placeholder
|
||||
// so the backend's "no body" guard doesn't fail. The user_hint carries
|
||||
// the user's compose intent; the model uses To/Subject + that hint.
|
||||
const bodyForApi = currentBody || (noteHint ? '(no prior email — compose a new message based on the To, Subject, and user instructions)' : currentBody);
|
||||
const fastFlag = mode === 'ai-reply-fast' ? true
|
||||
: mode === 'ai-reply-full' ? false
|
||||
: shouldUseFastAiReply();
|
||||
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
to: to,
|
||||
subject: subject,
|
||||
original_body: currentBody,
|
||||
original_body: bodyForApi,
|
||||
model: currentModel,
|
||||
session_id: currentSessionId,
|
||||
message_id: inReplyTo,
|
||||
uid: sourceUid,
|
||||
folder: sourceFolder,
|
||||
fast: shouldUseFastAiReply(),
|
||||
fast: fastFlag,
|
||||
user_hint: noteHint || '',
|
||||
}),
|
||||
});
|
||||
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) {
|
||||
newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n');
|
||||
} else {
|
||||
newBody = cleanReply + (currentBody ? '\n\n' + currentBody : '');
|
||||
}
|
||||
let cleanReply = cleanAiReplyText(data.reply);
|
||||
// Strip any "On <date>, <name> wrote:" attribution + everything
|
||||
// after it from the AI's output — the model sometimes re-quotes
|
||||
// the original thread, and we already have the real quote in
|
||||
// currentBody. Without this, AI's invented quote stacked on top
|
||||
// of the real one and looked like the history had been "edited".
|
||||
cleanReply = cleanReply.replace(/\n*On\b[\s\S]*?\bwrote:[\s\S]*$/m, '').trim();
|
||||
// Never overwrite the existing draft (user's typed text + the
|
||||
// quoted history below it). Always prepend the AI suggestion so
|
||||
// the user can read it, copy parts, or delete it — but their
|
||||
// own work and the original quote are untouched.
|
||||
const newBody = currentBody ? cleanReply + '\n\n' + currentBody : cleanReply;
|
||||
await _streamEmailBodyText(textarea, newBody);
|
||||
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
|
||||
} else {
|
||||
@@ -3285,7 +3388,7 @@ import * as Modals from './modalManager.js';
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showError('Failed to generate AI reply');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>AI Reply'; }
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;margin-left:4px;">Reply</span>'; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3813,7 +3916,6 @@ import * as Modals from './modalManager.js';
|
||||
<button id="doc-export-pdf-btn" class="doc-action-icon-btn" title="Export PDF" style="display:none;opacity:0.7;gap:4px;"><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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><polyline points="9 15 12 18 15 15"/></svg> <span style="font-size:11px;">Export PDF</span></button>
|
||||
<button id="doc-pdf-view-btn" class="doc-action-icon-btn" title="Toggle PDF view" style="display:none;opacity:0.7;gap:4px;"><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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> <span style="font-size:11px;">PDF</span></button>
|
||||
<select id="doc-language-select" class="doc-language-select">
|
||||
<option value="">type</option>
|
||||
<option value="python">python</option>
|
||||
<option value="javascript">javascript</option>
|
||||
<option value="typescript">typescript</option>
|
||||
@@ -3881,13 +3983,14 @@ import * as Modals from './modalManager.js';
|
||||
<div class="doc-md-toolbar" id="doc-md-toolbar" style="display:none">
|
||||
<div class="md-toolbar-items" id="md-toolbar-items">
|
||||
<span class="md-view-toggle" id="doc-md-view-toggle" style="display:none" role="group" aria-label="Edit or preview">
|
||||
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</span>
|
||||
<span class="md-view-toggle" id="doc-render-view-toggle" style="display:none" role="group" aria-label="Code or run">
|
||||
<button type="button" class="md-view-opt" data-renderview="code" title="Edit code"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-renderview="run" title="Run / Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
||||
</span>
|
||||
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (Fast / Full + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
|
||||
<button id="doc-fontsize-btn" class="doc-action-icon-btn" title="Font size" style="position:relative;width:28px;height:26px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M4 7V4h16v3"/><path d="M12 4v16"/><path d="M8 20h8"/></svg><span class="doc-fontsize-levels"><i data-sz="s">S</i><i data-sz="m">M</i><i data-sz="l">L</i></span></button>
|
||||
<button id="doc-diff-toggle-btn" class="doc-action-icon-btn" title="Compare changes" style="opacity:0.7;display:none;"><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="M12 3v18"/><path d="M5 12H2l5-5 5 5H9"/><path d="M19 12h3l-5 5-5-5h3"/></svg></button>
|
||||
<span class="md-toolbar-sep"></span>
|
||||
@@ -4397,6 +4500,24 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
});
|
||||
}
|
||||
// Ctrl+Alt+M (and Cmd+Opt+M on mac) flips Edit ↔ Preview on a markdown
|
||||
// doc. Bound once globally; gated on the doc panel being open and the
|
||||
// active doc being markdown so it doesn't fire while the user is typing
|
||||
// in a non-markdown context.
|
||||
if (!window._docMdToggleBound) {
|
||||
window._docMdToggleBound = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey && (e.key === 'm' || e.key === 'M' || e.code === 'KeyM')) {
|
||||
if (!isOpen) return;
|
||||
const doc = activeDocId && docs.get(activeDocId);
|
||||
const lang = (doc?.language || 'markdown').toLowerCase();
|
||||
if (lang !== 'markdown') return;
|
||||
e.preventDefault();
|
||||
toggleMarkdownPreview();
|
||||
_syncHeaderActions();
|
||||
}
|
||||
});
|
||||
}
|
||||
document.getElementById('doc-email-draft-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('doc-email-more-menu').style.display = 'none';
|
||||
_saveDraft();
|
||||
@@ -4411,7 +4532,11 @@ import * as Modals from './modalManager.js';
|
||||
document.getElementById('doc-email-more-menu').style.display = 'none';
|
||||
_scheduleSend(anchor);
|
||||
});
|
||||
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply);
|
||||
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
_showDocAiReplyChoice(ev.currentTarget);
|
||||
});
|
||||
|
||||
const collapseBtn = document.getElementById('doc-email-collapse-btn');
|
||||
if (collapseBtn && !collapseBtn._emailCollapseWired) {
|
||||
|
||||
@@ -125,12 +125,12 @@ export function init(documentModule) {
|
||||
_watchDocOpenToReDockEmail();
|
||||
}
|
||||
|
||||
export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply') {
|
||||
export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply', prefilledBody = '') {
|
||||
if (!uid) return;
|
||||
const previousFolder = _currentFolder;
|
||||
_currentFolder = folder || 'INBOX';
|
||||
try {
|
||||
await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply');
|
||||
await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply', '', prefilledBody || '');
|
||||
} finally {
|
||||
_currentFolder = previousFolder || _currentFolder;
|
||||
}
|
||||
@@ -630,10 +630,13 @@ function _createEmailItem(em) {
|
||||
return item;
|
||||
}
|
||||
|
||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '') {
|
||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
|
||||
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : (mode === 'ai-reply-full' ? 'full' : '');
|
||||
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
|
||||
let aiSuggestedBody = null;
|
||||
// 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;
|
||||
if (wantsAiReply) {
|
||||
// Fall through to reply-all (not plain reply) so the generated AI
|
||||
// draft addresses everyone on the original thread. On single-
|
||||
|
||||
+272
-12
@@ -30,6 +30,7 @@ let _libLoadSeq = 0;
|
||||
let _libFolderSeq = 0;
|
||||
let _libSearchSeq = 0;
|
||||
let _libSearchHadResults = false;
|
||||
let _libSearchInFlight = false;
|
||||
let _activeEmailReaderForSelectAll = null;
|
||||
|
||||
function _isEmailTypingTarget(t) {
|
||||
@@ -62,6 +63,52 @@ function _markEmailReaderActive(reader) {
|
||||
reader.addEventListener('focusin', () => { _activeEmailReaderForSelectAll = reader; }, true);
|
||||
}
|
||||
|
||||
// Stash the email identity (uid + folder + account) on the reader element
|
||||
// so chat submits and other code paths can ask "what email is the user
|
||||
// currently looking at?" without re-deriving from the DOM hierarchy.
|
||||
function _stampReaderContext(reader, em, folder, account) {
|
||||
if (!reader || !em) return;
|
||||
reader.dataset.emailUid = String(em.uid || '');
|
||||
reader.dataset.emailFolder = String(folder || state._libFolder || 'INBOX');
|
||||
reader.dataset.emailAccount = String(account || state._libAccountId || '');
|
||||
if (em.subject) reader.dataset.emailSubject = String(em.subject);
|
||||
if (em.from_address || em.from_name) {
|
||||
reader.dataset.emailFrom = String(em.from_address || em.from_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns { uid, folder, account, subject, from } for the email the user
|
||||
// is most likely referring to — the last reader they interacted with, then
|
||||
// any open reader-modal as a fallback. Returns null when no email reader
|
||||
// is open. Exported below for chat.js to read on submit.
|
||||
function _getActiveEmailContext() {
|
||||
const candidates = [];
|
||||
if (_activeEmailReaderForSelectAll && _activeEmailReaderForSelectAll.isConnected) {
|
||||
candidates.push(_activeEmailReaderForSelectAll);
|
||||
}
|
||||
// Visible reader-tab modals (popped-out windows).
|
||||
document.querySelectorAll('.modal[id^="email-reader-"]:not(.hidden):not(.modal-minimized) .email-card-reader').forEach(el => candidates.push(el));
|
||||
// Expanded inline reader in the library list.
|
||||
document.querySelectorAll('#email-lib-modal:not(.hidden) .doclib-card.email-card-expanded .email-card-reader').forEach(el => candidates.push(el));
|
||||
for (const r of candidates) {
|
||||
const uid = r?.dataset?.emailUid;
|
||||
if (uid) {
|
||||
return {
|
||||
uid,
|
||||
folder: r.dataset.emailFolder || 'INBOX',
|
||||
account: r.dataset.emailAccount || '',
|
||||
subject: r.dataset.emailSubject || '',
|
||||
from: r.dataset.emailFrom || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Frontend reads via the global so chat.js doesn't need a separate import
|
||||
// path (emailLibrary loads lazily in some entry points).
|
||||
try { window.__odysseusGetActiveEmailContext = _getActiveEmailContext; } catch (_) {}
|
||||
|
||||
const _COPY_EMAIL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
|
||||
function _decodeAttrValue(v) {
|
||||
@@ -710,7 +757,7 @@ async function _prewarmDefaultEmailView() {
|
||||
} catch (_) {}
|
||||
|
||||
const accountQS = accountId ? `&account_id=${encodeURIComponent(accountId)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${accountQS}&limit=100&offset=0&filter=${filter}`, {
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${accountQS}&limit=500&offset=0&filter=${filter}`, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) return;
|
||||
@@ -1876,8 +1923,21 @@ function _applyPillFilter() {
|
||||
return;
|
||||
}
|
||||
const source = _libPreSearchEmails || state._libEmails || [];
|
||||
const draftPill = draft.length >= 1 ? { type: 'text', text: draft } : null;
|
||||
const effective = draftPill ? pills.concat([draftPill]) : pills;
|
||||
// If the active server search covers a piece of text (either the live
|
||||
// draft OR an Enter-committed text pill), skip the local re-filter for
|
||||
// it — _emailMatchesPill only checks subject/from_name/from_address/
|
||||
// snippet (no BODY), so it was dropping legitimate server hits where
|
||||
// the match was in body text. Real pills (contact, filter chips) still
|
||||
// apply, and other text pills with different strings still apply.
|
||||
const libSearchLower = (_libSearchHadResults ? (state._libSearch || '').trim().toLowerCase() : '');
|
||||
const serverHandledDraft = !!(libSearchLower && draft && libSearchLower === draft.toLowerCase());
|
||||
const draftPill = (!serverHandledDraft && draft.length >= 1) ? { type: 'text', text: draft } : null;
|
||||
// Filter out text pills whose text matches the active server search —
|
||||
// those were the trigger for the IMAP query and don't need re-checking.
|
||||
const effectiveBasePills = libSearchLower
|
||||
? pills.filter(p => !(p.type === 'text' && (p.text || '').toLowerCase() === libSearchLower))
|
||||
: pills;
|
||||
const effective = draftPill ? effectiveBasePills.concat([draftPill]) : effectiveBasePills;
|
||||
// AND across pills — "alice + bob" should mean both alice AND bob are
|
||||
// somewhere on the email (from/to/cc), not "from alice OR from bob".
|
||||
const filtered = source.filter(em => effective.every(p => _emailMatchesPill(em, p)));
|
||||
@@ -1990,6 +2050,24 @@ function _removeSearchPillAt(idx) {
|
||||
state._libSearchPills.splice(idx, 1);
|
||||
if (removed && removed.type === 'filter') _clearFilterPillSideEffect();
|
||||
_renderSearchPills();
|
||||
// Pill cleared all the way: if we got into search-result mode via the
|
||||
// IMAP search, the pre-search snapshot is now those results too (set
|
||||
// in _doSearch). Restoring from it would leave the user staring at
|
||||
// the same results with the pill bar empty. Re-fetch the real inbox
|
||||
// so removing the last pill genuinely "goes back".
|
||||
const noPillsLeft = (state._libSearchPills || []).length === 0
|
||||
&& !(state._libSearchDraft || '').trim();
|
||||
if (noPillsLeft && _libSearchHadResults) {
|
||||
_libSearchHadResults = false;
|
||||
_libPreSearchEmails = null;
|
||||
_libPreSearchTotal = 0;
|
||||
state._libSearch = '';
|
||||
state._libOffset = 0;
|
||||
const _searchInput = document.getElementById('email-lib-search');
|
||||
if (_searchInput) _searchInput.value = '';
|
||||
_loadEmails({ useCache: true });
|
||||
return;
|
||||
}
|
||||
_applyPillFilter();
|
||||
}
|
||||
|
||||
@@ -2059,6 +2137,15 @@ function _acceptSuggestion(s) {
|
||||
return;
|
||||
} else {
|
||||
_addSearchPill({ type: 'contact', name: s.name, email: s.email });
|
||||
// Same as the text-pill path in the Enter handler: trigger the IMAP
|
||||
// search so unloaded emails (older than the current page) show up
|
||||
// when picking a contact. The local pill filter then narrows the
|
||||
// search results to that contact's address.
|
||||
const _q = (s.email || s.name || '').trim();
|
||||
if (_q && _q.length >= 2) {
|
||||
state._libSearch = _q;
|
||||
_doSearch();
|
||||
}
|
||||
}
|
||||
if (input) input.value = '';
|
||||
state._libSearchDraft = '';
|
||||
@@ -2099,10 +2186,47 @@ async function _initEmailSearchChipBar() {
|
||||
};
|
||||
|
||||
input.addEventListener('focus', _refreshSuggestions);
|
||||
// Debounced IMAP search — fires ~500ms after the user stops typing so
|
||||
// searches for names/text not in the current inbox page actually surface
|
||||
// hits, instead of just locally filtering the visible window.
|
||||
//
|
||||
// Live local filtering on EVERY keystroke was clobbering server hits:
|
||||
// _emailMatchesPill / _matchesQuery check subject/from_name/from_address/
|
||||
// snippet but never body, so intermediate text like "sam" reduced the
|
||||
// 61 server results to whatever matched just those four fields (often
|
||||
// 0). User saw "no emails" while typing. So local filter is gone from
|
||||
// the typing path — debounced server search drives the grid. Pill
|
||||
// add/remove still re-runs the local filter through _applyPillFilter
|
||||
// directly.
|
||||
let _libSearchTypingTimer = null;
|
||||
input.addEventListener('input', async () => {
|
||||
state._libSearchDraft = input.value;
|
||||
try { console.log('[email-search] input event, value=', JSON.stringify(input.value)); } catch {}
|
||||
await _refreshSuggestions();
|
||||
_applyPillFilter();
|
||||
if (_libSearchTypingTimer) clearTimeout(_libSearchTypingTimer);
|
||||
const v = input.value.trim();
|
||||
if (v.length >= 2) {
|
||||
_libSearchTypingTimer = setTimeout(() => {
|
||||
const cur = (input.value || '').trim();
|
||||
if (cur === v && cur.length >= 2) {
|
||||
state._libSearch = cur;
|
||||
try { console.log('[email-search] firing _doSearch for', cur); } catch {}
|
||||
_doSearch();
|
||||
} else {
|
||||
try { console.log('[email-search] debounce expired but input changed (was', v, 'now', cur, ')'); } catch {}
|
||||
}
|
||||
}, 500);
|
||||
} else if (!v && _libSearchHadResults) {
|
||||
// Cleared the input → restore the inbox the same way the pill-clear
|
||||
// path does. Otherwise the stale search results stayed up after the
|
||||
// user backspaced everything out.
|
||||
_libSearchHadResults = false;
|
||||
_libPreSearchEmails = null;
|
||||
_libPreSearchTotal = 0;
|
||||
state._libSearch = '';
|
||||
state._libOffset = 0;
|
||||
_loadEmails({ useCache: true });
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
// Delay so click/mousedown on a suggestion fires first.
|
||||
@@ -2154,6 +2278,14 @@ async function _initEmailSearchChipBar() {
|
||||
input.value = '';
|
||||
state._libSearchDraft = '';
|
||||
_hideSearchSuggestions();
|
||||
// Pill-only filtering used to only check emails already loaded into
|
||||
// state._libEmails (the visible page of the inbox). Searches for
|
||||
// names/text that aren't in the current page returned "no emails"
|
||||
// even when matches existed on the server. Trigger the IMAP
|
||||
// search so state._libEmails is replaced with the actual hits,
|
||||
// then the pill filter narrows to matches.
|
||||
state._libSearch = v;
|
||||
_doSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2232,9 +2364,16 @@ async function _doSearch() {
|
||||
const stats = document.getElementById('email-lib-stats');
|
||||
const originalStatsText = stats?.textContent || '';
|
||||
if (stats) stats.textContent = 'Searching…';
|
||||
_libSearchInFlight = true;
|
||||
// Force a re-render so the "Searching…" empty-state shows (and any
|
||||
// existing "No emails" gets replaced) while the fetch is in flight.
|
||||
_renderGrid();
|
||||
|
||||
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
try {
|
||||
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
// Single fast fetch — limit=100 so the IMAP fetch loop doesn't spend
|
||||
// 60 s pulling 500 headers serially. We can wire "Load more" later
|
||||
// off `state._libTotal` if needed.
|
||||
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(folderAtStart)}${accountQS}&q=${encodeURIComponent(q)}&limit=100`);
|
||||
const data = await res.json();
|
||||
if (
|
||||
@@ -2251,11 +2390,36 @@ async function _doSearch() {
|
||||
_libSearchHadResults = true;
|
||||
state._libEmails = results; // temporarily replace with search results
|
||||
state._libTotal = data.total || results.length;
|
||||
// Refresh the pre-search snapshot so any subsequent _applyPillFilter
|
||||
// call (focus / pill edit / etc.) sources from the actual search
|
||||
// results, not the stale inbox page that was loaded before the
|
||||
// search ran. Without this, active pills (a contact pill from the
|
||||
// suggestion the user just clicked) would filter the inbox snapshot
|
||||
// → near-always empty → user sees "no emails" even though the
|
||||
// server search succeeded.
|
||||
_libPreSearchEmails = results.slice();
|
||||
_libPreSearchTotal = state._libTotal;
|
||||
// If pills are active (and they usually are after a contact-pill or
|
||||
// text-pill add), re-run the pill filter so the visible grid is the
|
||||
// pill-narrowed intersection of the new search results. Otherwise
|
||||
// _renderGrid below would render the raw server response, which
|
||||
// might not match the active pills the user just added.
|
||||
if ((state._libSearchPills || []).length) {
|
||||
_applyPillFilter();
|
||||
// Fall back to rendering the raw results if the pill intersection
|
||||
// hid everything but the user just confirmed they want this query.
|
||||
if (!(state._libEmails || []).length) state._libEmails = results;
|
||||
}
|
||||
_renderGrid();
|
||||
|
||||
if (stats) stats.textContent = `${data.total || results.length} match${(data.total || results.length) === 1 ? '' : 'es'}`;
|
||||
const count = data.total || results.length;
|
||||
if (stats) stats.textContent = `${count} match${count === 1 ? '' : 'es'} on server`;
|
||||
try { console.log('[email-search]', JSON.stringify({ q, folder: folderAtStart, count, returned: results.length })); } catch {}
|
||||
} catch (e) {
|
||||
if (stats) stats.textContent = originalStatsText || 'Search failed';
|
||||
try { console.error('[email-search] fetch failed:', e); } catch {}
|
||||
} finally {
|
||||
_libSearchInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2584,6 +2748,7 @@ function _renderGrid() {
|
||||
grid.innerHTML = '';
|
||||
|
||||
let filtered = state._libEmails;
|
||||
try { console.log('[email-search] _renderGrid: state._libEmails.length=', (state._libEmails || []).length, 'pills=', (state._libSearchPills || []).length, 'draft=', JSON.stringify(state._libSearchDraft || ''), 'libSearch=', JSON.stringify(state._libSearch || '')); } catch {}
|
||||
|
||||
// Apply sort
|
||||
if (state._libSort === 'unread') {
|
||||
@@ -2592,8 +2757,39 @@ function _renderGrid() {
|
||||
filtered = [...filtered].sort((a, b) => Number(b.is_flagged) - Number(a.is_flagged));
|
||||
}
|
||||
// 'recent' is the default order from the API
|
||||
// Stable secondary sort: favorited (is_flagged) emails ALWAYS bubble to
|
||||
// the top of whatever order the sort above produced. This pins the
|
||||
// user's flagged items so they're the first thing in the inbox no
|
||||
// matter which sort mode is active.
|
||||
filtered = [...filtered].sort((a, b) => Number(!!b.is_flagged) - Number(!!a.is_flagged));
|
||||
|
||||
if (filtered.length === 0) {
|
||||
// Active search — don't flash "No emails": the IMAP fetch is still
|
||||
// running. Show a "Searching…" placeholder until _doSearch resolves
|
||||
// and renders again. Without this the user saw an empty state
|
||||
// smiley for ~500ms between the optimistic pill-filter clear and
|
||||
// the server response landing.
|
||||
if (_libSearchInFlight) {
|
||||
grid.innerHTML = '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'email-loading';
|
||||
wrap.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;padding:24px;opacity:0.75;';
|
||||
grid.appendChild(wrap);
|
||||
// Whirlpool spinner for parity with the rest of the cookbook /
|
||||
// doclib loaders. Falls back to plain text if the import fails.
|
||||
import('./spinner.js').then(sp => {
|
||||
if (!wrap.isConnected) return;
|
||||
const w = sp.default.createWhirlpool(20);
|
||||
w.element.style.cssText = 'margin:0;display:block;';
|
||||
wrap.appendChild(w.element);
|
||||
const lbl = document.createElement('span');
|
||||
lbl.textContent = 'Searching…';
|
||||
wrap.appendChild(lbl);
|
||||
}).catch(() => {
|
||||
wrap.textContent = 'Searching…';
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Inbox-zero is a win — pair the message with a small smiley so the
|
||||
// empty state reads as "all caught up", not "something's broken".
|
||||
const _smileyIco = '<span style="vertical-align:-3px;margin-left:6px;">' + emptyStateIcon('smiley') + '</span>';
|
||||
@@ -2930,13 +3126,19 @@ function _prefetchAdjacentEmails(card, count = 1) {
|
||||
const target = targets.find(t => t?.dataset?.uid);
|
||||
const uid = target?.dataset?.uid;
|
||||
if (!uid) return;
|
||||
const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
|
||||
// Use the email's actual folder when it was stamped by the search
|
||||
// endpoint; otherwise default to the currently-selected folder.
|
||||
const _emFold = (() => {
|
||||
const emObj = (state._libEmails || []).find(e => String(e.uid) === String(uid));
|
||||
return (emObj && emObj.folder) || state._libFolder || 'INBOX';
|
||||
})();
|
||||
const key = `${state._libAccountId || ''}|${_emFold}|${uid}`;
|
||||
if (_emailReadPrefetching.has(key) || _emailReadPrefetching.size > 0) return;
|
||||
if (_emailReadPrefetchTimer) clearTimeout(_emailReadPrefetchTimer);
|
||||
_emailReadPrefetchTimer = setTimeout(() => {
|
||||
_emailReadPrefetchTimer = null;
|
||||
_emailReadPrefetching.add(key);
|
||||
fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&mark_seen=false`)
|
||||
fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(_emFold)}${_acct()}&mark_seen=false`)
|
||||
.catch(() => {})
|
||||
.finally(() => _emailReadPrefetching.delete(key));
|
||||
}, 900);
|
||||
@@ -2944,7 +3146,10 @@ function _prefetchAdjacentEmails(card, count = 1) {
|
||||
|
||||
async function _toggleCardPreview(card, em) {
|
||||
const accountAtStart = state._libAccountId || '';
|
||||
const folderAtStart = state._libFolder || 'INBOX';
|
||||
// Prefer the per-email folder stamped by the search endpoint (results
|
||||
// from "All Mail" carry folder="[Gmail]/All Mail"). Falls back to the
|
||||
// currently-selected folder for normal inbox cards.
|
||||
const folderAtStart = (em && em.folder) || state._libFolder || 'INBOX';
|
||||
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
|
||||
const grid = card.closest('.doclib-grid');
|
||||
const gridRect = grid?.getBoundingClientRect?.();
|
||||
@@ -3036,6 +3241,7 @@ async function _toggleCardPreview(card, em) {
|
||||
// Mark as read locally
|
||||
_syncEmailReadState(em.uid, true);
|
||||
_prefetchAdjacentEmails(card);
|
||||
_stampReaderContext(reader, { ...em, ...data }, state._libFolder, state._libAccountId);
|
||||
|
||||
// Build the attachments wrap using the shared helper so the signature-
|
||||
// image filter (small inline PNGs/JPGs, Outlook image001 placeholders,
|
||||
@@ -3083,10 +3289,10 @@ async function _toggleCardPreview(card, em) {
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildRecipients(data.cc)}</span></div>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="email-reader-actions-inline">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><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><span class="reader-btn-label">Reply</span></button>
|
||||
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><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="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><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="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="summarize" title="Summarize">${_summaryIcon(data)}<span class="reader-btn-label">Summary</span></button>
|
||||
<div class="email-reader-more-wrap" style="position:relative">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
|
||||
@@ -4758,6 +4964,7 @@ async function _openEmailAsTab(em, folder) {
|
||||
return;
|
||||
}
|
||||
_syncEmailReadState(em.uid, true);
|
||||
_stampReaderContext(reader, { ...em, ...data }, useFolder, state._libAccountId);
|
||||
const buildChips = (str) => {
|
||||
if (!str) return '';
|
||||
return _splitRecipientList(str).map(a => {
|
||||
@@ -4780,10 +4987,10 @@ async function _openEmailAsTab(em, folder) {
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildChips(data.cc)}</span></div>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="email-reader-actions-inline">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><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><span class="reader-btn-label">Reply</span></button>
|
||||
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><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="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><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="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="summarize" title="Summarize">${_summaryIcon(data)}<span class="reader-btn-label">Summary</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="from-sender" title="Search text in this thread"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><span class="reader-btn-label">Search</span></button>
|
||||
<div class="email-reader-more-wrap" style="position:relative">
|
||||
@@ -4936,10 +5143,10 @@ async function _openEmailWindow(em, folder) {
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${_chipsFor(data.cc)}</span></div>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="email-reader-actions-inline">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><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><span class="reader-btn-label">Reply</span></button>
|
||||
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><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="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><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="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="summarize" title="Summarize">${_summaryIcon(data)}<span class="reader-btn-label">Summary</span></button>
|
||||
<div class="email-reader-more-wrap" style="position:relative">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
|
||||
@@ -5337,6 +5544,27 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
_renderGrid();
|
||||
},
|
||||
},
|
||||
{
|
||||
// Favorite (pin to top). Same bookmark glyph we use for the
|
||||
// sidebar-pin / favorites filter so the visual language stays
|
||||
// consistent. Toggling updates em.is_flagged and re-sorts via
|
||||
// _renderGrid (favorited rows are always pinned at the top).
|
||||
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : '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>',
|
||||
action: async () => {
|
||||
const next = !em.is_flagged;
|
||||
em.is_flagged = next;
|
||||
_renderGrid();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
// Roll back the optimistic flip if the server didn't take it.
|
||||
em.is_flagged = !next;
|
||||
_renderGrid();
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: em.is_answered ? 'Mark as Not Done' : 'Mark as Done',
|
||||
icon: _checkIcon,
|
||||
@@ -5551,6 +5779,22 @@ function _showCardMenu(em, anchor) {
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.push({
|
||||
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : '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>',
|
||||
action: async () => {
|
||||
const next = !em.is_flagged;
|
||||
em.is_flagged = next;
|
||||
_renderGrid();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
em.is_flagged = !next;
|
||||
_renderGrid();
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.push({
|
||||
label: 'Archive',
|
||||
icon: _archIcon,
|
||||
@@ -5563,6 +5807,22 @@ function _showCardMenu(em, anchor) {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : '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>',
|
||||
action: async () => {
|
||||
const next = !em.is_flagged;
|
||||
em.is_flagged = next;
|
||||
_renderGrid();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
em.is_flagged = !next;
|
||||
_renderGrid();
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.push({
|
||||
label: 'Archive',
|
||||
icon: _archIcon,
|
||||
|
||||
+82
-4
@@ -18,6 +18,80 @@ let selectedIds = new Set();
|
||||
|
||||
const MEMORY_CATEGORIES = ['fact', 'identity', 'preference', 'contact', 'project', 'goal', 'task'];
|
||||
|
||||
// Sort-option icons for the custom Memory sort picker (and Skills picker
|
||||
// once it reuses the same markup). Each value maps to a 13px Feather-style
|
||||
// SVG so the icon visually distinguishes Newest / Oldest / A-Z / Most used.
|
||||
const _MEMORY_SORT_ICONS = {
|
||||
newest: '<svg width="13" height="13" 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"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||
oldest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><polyline points="3 3 3 8 8 8"/><polyline points="12 7 12 12 16 14"/></svg>',
|
||||
alpha: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h6"/><path d="M3 10h6"/><path d="M3 16h4"/><path d="M14 4l4 12"/><path d="M16 12h4"/><polyline points="17 18 21 14 17 10"/><line x1="21" y1="14" x2="13" y2="14"/></svg>',
|
||||
uses: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>',
|
||||
};
|
||||
|
||||
function _memorySortIcon(value) {
|
||||
return _MEMORY_SORT_ICONS[value] || _MEMORY_SORT_ICONS.newest;
|
||||
}
|
||||
|
||||
function _renderMemorySortPickerCurrent() {
|
||||
const sel = document.getElementById('memory-sort');
|
||||
const btn = document.getElementById('memory-sort-btn');
|
||||
if (!sel || !btn) return;
|
||||
const value = sel.value || 'newest';
|
||||
const opt = sel.querySelector(`option[value="${CSS.escape(value)}"]`);
|
||||
const label = opt ? opt.textContent : value;
|
||||
const iconWrap = btn.querySelector('.memory-sort-icon-cur');
|
||||
const labelEl = btn.querySelector('.memory-sort-label');
|
||||
if (iconWrap) iconWrap.innerHTML = _memorySortIcon(value);
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
}
|
||||
|
||||
function _initMemorySortPicker() {
|
||||
const sel = document.getElementById('memory-sort');
|
||||
const picker = document.getElementById('memory-sort-picker');
|
||||
const btn = document.getElementById('memory-sort-btn');
|
||||
const menu = document.getElementById('memory-sort-menu');
|
||||
if (!sel || !picker || !btn || !menu || picker._wired) return;
|
||||
picker._wired = true;
|
||||
|
||||
const items = Array.from(sel.children)
|
||||
.filter(o => o.tagName === 'OPTION')
|
||||
.map(o => ({ value: o.value, label: o.textContent }));
|
||||
|
||||
menu.innerHTML = items.map(it => `
|
||||
<button type="button" role="option" class="memory-sort-item" data-value="${it.value}">
|
||||
<span class="memory-sort-item-icon">${_memorySortIcon(it.value)}</span>
|
||||
<span class="memory-sort-item-label">${it.label}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
const close = () => { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); };
|
||||
const open = () => { menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); };
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (menu.hidden) open(); else close();
|
||||
});
|
||||
menu.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.memory-sort-item');
|
||||
if (!item) return;
|
||||
sel.value = item.dataset.value;
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
_renderMemorySortPickerCurrent();
|
||||
close();
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.hidden && !picker.contains(e.target)) close();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !menu.hidden) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}, { capture: true });
|
||||
|
||||
_renderMemorySortPickerCurrent();
|
||||
}
|
||||
|
||||
function _ensureNewMemoryCategorySelect() {
|
||||
const sel = document.getElementById('new-memory-category');
|
||||
if (!sel || sel.dataset.wired === '1') return;
|
||||
@@ -334,13 +408,16 @@ export async function loadMemories() {
|
||||
|
||||
// ---- Bulk select mode ----
|
||||
|
||||
const _SELECT_BTN_DOT_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>';
|
||||
const _SELECT_BTN_X_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:3px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
|
||||
function enterSelectMode() {
|
||||
selectMode = true;
|
||||
selectedIds.clear();
|
||||
const bulkBar = document.getElementById('memory-bulk-bar');
|
||||
const selectBtn = document.getElementById('memory-select-btn');
|
||||
if (bulkBar) bulkBar.classList.remove('hidden');
|
||||
if (selectBtn) { selectBtn.classList.add('active'); selectBtn.textContent = 'Cancel'; }
|
||||
if (selectBtn) { selectBtn.classList.add('active'); selectBtn.innerHTML = _SELECT_BTN_X_SVG + 'Cancel'; }
|
||||
updateBulkCount();
|
||||
renderMemoryList();
|
||||
}
|
||||
@@ -352,7 +429,7 @@ function exitSelectMode() {
|
||||
const selectBtn = document.getElementById('memory-select-btn');
|
||||
const selectAll = document.getElementById('memory-select-all');
|
||||
if (bulkBar) bulkBar.classList.add('hidden');
|
||||
if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.textContent = 'Select'; }
|
||||
if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.innerHTML = _SELECT_BTN_DOT_SVG + 'Select'; }
|
||||
if (selectAll) selectAll.checked = false;
|
||||
renderMemoryList();
|
||||
}
|
||||
@@ -449,7 +526,7 @@ export async function tidyMemories() {
|
||||
const data = await res.json();
|
||||
if ((data.removed || 0) === 0) {
|
||||
if (tidySpinner) tidySpinner.destroy();
|
||||
if (tidyBtn) { tidyBtn.disabled = false; tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; }
|
||||
if (tidyBtn) { tidyBtn.disabled = false; tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; }
|
||||
showToast('Already clean');
|
||||
return;
|
||||
}
|
||||
@@ -492,7 +569,7 @@ export async function tidyMemories() {
|
||||
tidyBtn.disabled = false;
|
||||
tidyBtn.style.border = '';
|
||||
tidyBtn.style.background = '';
|
||||
tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy';
|
||||
tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1387,6 +1464,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
renderMemoryList();
|
||||
});
|
||||
}
|
||||
_initMemorySortPicker();
|
||||
|
||||
const tidyBtn = document.getElementById('memory-tidy-btn');
|
||||
if (tidyBtn) tidyBtn.addEventListener('click', tidyMemories);
|
||||
|
||||
Reference in New Issue
Block a user