mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-10 12:17:11 +00:00
Polish mobile UI and editor workflows
This commit is contained in:
+49
-7
@@ -1617,6 +1617,7 @@ function initializeEventListeners() {
|
||||
// Delay tool glow-up for a staggered effect
|
||||
setTimeout(() => applyModeToToggles(mode), 500);
|
||||
}
|
||||
window.__odysseusSetChatMode = setMode;
|
||||
agentBtn.addEventListener('click', () => {
|
||||
// Agent mode turns off research if active
|
||||
const resChk = el('research-toggle');
|
||||
@@ -1692,10 +1693,29 @@ function initializeEventListeners() {
|
||||
try { workspaceModule.initWorkspace(); } catch (_) {}
|
||||
|
||||
// Document editor toggle (special: uses module panel, not a checkbox)
|
||||
function bringOpenDocumentToFrontOnMobile() {
|
||||
if (window.innerWidth > 768) return false;
|
||||
if (!documentModule || !documentModule.isPanelOpen || !documentModule.isPanelOpen()) return false;
|
||||
if (!document.body.classList.contains('email-front')) return false;
|
||||
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');
|
||||
const docPane = document.getElementById('doc-editor-pane');
|
||||
if (docPane) docPane.style.setProperty('z-index', '10010', 'important');
|
||||
const overflow = el('overflow-doc-btn');
|
||||
if (overflow) overflow.classList.add('active');
|
||||
const indicator = el('doc-indicator-btn');
|
||||
if (indicator) indicator.classList.add('active');
|
||||
const st = loadToggleState(); st.doc = true; saveToggleState(st);
|
||||
return true;
|
||||
}
|
||||
|
||||
const overflowDocBtn = el('overflow-doc-btn');
|
||||
if (overflowDocBtn) {
|
||||
overflowDocBtn.addEventListener('click', async () => {
|
||||
if (!documentModule) return;
|
||||
if (bringOpenDocumentToFrontOnMobile()) return;
|
||||
if (documentModule.isPanelOpen()) {
|
||||
documentModule.closePanel();
|
||||
overflowDocBtn.classList.remove('active');
|
||||
@@ -2304,14 +2324,32 @@ function initializeEventListeners() {
|
||||
// IMPORTANT: don't overwrite the user's persisted per-mode tool prefs
|
||||
// (`web_agent`, `bash_agent`, `web_chat`, `bash_chat`). Nobody mode is
|
||||
// ephemeral — their agent-mode defaults must come back on toggle-off.
|
||||
const beforeNobody = Storage.getJSON(Storage.KEYS.TOGGLES, {}) || {};
|
||||
if (!beforeNobody.nobody_prev_mode) beforeNobody.nobody_prev_mode = beforeNobody.mode || 'agent';
|
||||
Storage.setJSON(Storage.KEYS.TOGGLES, beforeNobody);
|
||||
const _offIds = ['web-toggle', 'bash-toggle', 'research-toggle'];
|
||||
_offIds.forEach(id => { const c = el(id); if (c) c.checked = false; });
|
||||
['web-toggle-btn', 'bash-toggle-btn'].forEach(id => { const b = el(id); if (b) b.classList.remove('active'); });
|
||||
const _ab = el('mode-agent-btn'), _cb = el('mode-chat-btn');
|
||||
if (_ab) _ab.classList.remove('active');
|
||||
if (_cb) _cb.classList.add('active');
|
||||
if (typeof window.__odysseusSetChatMode === 'function') {
|
||||
window.__odysseusSetChatMode('chat');
|
||||
} else {
|
||||
const _ab = el('mode-agent-btn'), _cb = el('mode-chat-btn');
|
||||
if (_ab) {
|
||||
_ab.classList.remove('active');
|
||||
_ab.setAttribute('aria-pressed', 'false');
|
||||
}
|
||||
if (_cb) {
|
||||
_cb.classList.add('active');
|
||||
_cb.setAttribute('aria-pressed', 'true');
|
||||
}
|
||||
const _toggle = _ab?.closest('.mode-toggle') || _cb?.closest('.mode-toggle');
|
||||
if (_toggle) _toggle.classList.add('mode-chat');
|
||||
const ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
|
||||
ts.mode = 'chat';
|
||||
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
|
||||
}
|
||||
const ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
|
||||
ts.research = false; ts.mode = 'chat';
|
||||
ts.research = false;
|
||||
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
|
||||
} else {
|
||||
incognitoBtn.innerHTML = INCOGNITO_EYE_OPEN + '<span class="incognito-label">Nobody</span>';
|
||||
@@ -2335,11 +2373,15 @@ function initializeEventListeners() {
|
||||
// Heal any previously-persisted false values from the old Nobody bug
|
||||
// so agent-mode defaults (web/bash ON) come back.
|
||||
const _ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
|
||||
let _dirty = false;
|
||||
const _restoreMode = _ts.nobody_prev_mode || 'agent';
|
||||
delete _ts.nobody_prev_mode;
|
||||
['web_agent', 'bash_agent', 'web_chat', 'bash_chat'].forEach(k => {
|
||||
if (_ts[k] === false) { delete _ts[k]; _dirty = true; }
|
||||
if (_ts[k] === false) delete _ts[k];
|
||||
});
|
||||
if (_dirty) Storage.setJSON(Storage.KEYS.TOGGLES, _ts);
|
||||
Storage.setJSON(Storage.KEYS.TOGGLES, _ts);
|
||||
if (typeof window.__odysseusSetChatMode === 'function') {
|
||||
window.__odysseusSetChatMode(_restoreMode === 'chat' ? 'chat' : 'agent');
|
||||
}
|
||||
// Reapply the current mode's real defaults to the visible toggles
|
||||
const _curMode = (Storage.getJSON(Storage.KEYS.TOGGLES, {}) || {}).mode || 'chat';
|
||||
try { applyModeToToggles(_curMode); } catch (_) {}
|
||||
|
||||
@@ -1952,6 +1952,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>Translation</h2>
|
||||
<div class="settings-row" style="align-items:center;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="settings-label" style="margin-bottom:3px;">Auto translate</div>
|
||||
<div class="admin-toggle-sub" style="margin:0;">When an opened email appears to be in another language, prepare a translated view.</div>
|
||||
</div>
|
||||
<label class="admin-switch"><input type="checkbox" id="set-email-auto-translate"><span class="admin-slider"></span></label>
|
||||
</div>
|
||||
<div class="settings-row" style="align-items:center;margin-top:8px;">
|
||||
<label class="settings-label" for="set-email-translate-language">Translate to</label>
|
||||
<input id="set-email-translate-language" class="settings-select" list="set-email-translate-language-list" placeholder="English, Swedish, Japanese..." style="max-width:220px;">
|
||||
<datalist id="set-email-translate-language-list">
|
||||
<option value="English"></option>
|
||||
<option value="Swedish"></option>
|
||||
<option value="Norwegian"></option>
|
||||
<option value="Danish"></option>
|
||||
<option value="Japanese"></option>
|
||||
<option value="Spanish"></option>
|
||||
<option value="French"></option>
|
||||
<option value="German"></option>
|
||||
<option value="British English"></option>
|
||||
<option value="Plain English"></option>
|
||||
</datalist>
|
||||
<span id="set-email-translate-msg" style="font-size:11px;margin-left:auto;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Writing Style</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">AI-extracted from your sent emails. Used when AI drafts replies.</div>
|
||||
|
||||
+6
-4
@@ -821,11 +821,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
// Web toggle: pre-search in Chat mode, tool permission in Agent mode
|
||||
const toggleState = Storage.loadToggleState();
|
||||
let isAgentMode = (toggleState.mode || 'chat') === 'agent';
|
||||
const incognitoChk = el('incognito-toggle');
|
||||
const isIncognito = !!(incognitoChk && incognitoChk.checked);
|
||||
// Auto-escalate to agent mode when a document is open — the user expects
|
||||
// the AI to see the document and have tools to edit it
|
||||
if (!isAgentMode && documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) {
|
||||
if (!isIncognito && !isAgentMode && documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) {
|
||||
isAgentMode = true;
|
||||
}
|
||||
if (isIncognito) isAgentMode = false;
|
||||
fd.append('mode', isAgentMode ? 'agent' : 'chat');
|
||||
if (el('web-toggle').checked) {
|
||||
if (isAgentMode) {
|
||||
@@ -846,8 +849,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (ragChk && !ragChk.checked) {
|
||||
fd.append('use_rag', 'false');
|
||||
}
|
||||
const incognitoChk = el('incognito-toggle');
|
||||
if (incognitoChk && incognitoChk.checked) {
|
||||
if (isIncognito) {
|
||||
fd.append('incognito', 'true');
|
||||
}
|
||||
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
|
||||
@@ -864,7 +866,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
currentAbort = abortCtrl;
|
||||
|
||||
const _tState = Storage.loadToggleState();
|
||||
const _isAgent = (_tState.mode || 'chat') === 'agent';
|
||||
const _isAgent = !isIncognito && (_tState.mode || 'chat') === 'agent';
|
||||
|
||||
// Timeout: 6 min for research and agent mode, 3 min otherwise
|
||||
const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
|
||||
|
||||
@@ -1068,6 +1068,17 @@ document.addEventListener('click', function(e) {
|
||||
}
|
||||
}, true);
|
||||
|
||||
function resolveDocumentPlaceholderLinks(text, metadata) {
|
||||
if (!text || !metadata || !Array.isArray(metadata.tool_events)) return text;
|
||||
const docEvents = metadata.tool_events.filter(ev => ev && ev.doc_id);
|
||||
if (!docEvents.length) return text;
|
||||
return String(text).replace(/#document-(\d+)\b/g, (match, num) => {
|
||||
const idx = Number(num) - 1;
|
||||
const ev = Number.isInteger(idx) && idx >= 0 ? docEvents[idx] : null;
|
||||
return ev && ev.doc_id ? `#document-${ev.doc_id}` : match;
|
||||
});
|
||||
}
|
||||
|
||||
// Jump-to-entity anchors — the agent emits links like
|
||||
// [New Chat](#session-89effa28)
|
||||
// [Notes](#document-abc123)
|
||||
@@ -2005,7 +2016,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
|
||||
for (let r = 0; r < maxRound; r++) {
|
||||
const roundNum = r + 1;
|
||||
const txt = (roundTexts[r] || '').trim();
|
||||
const txt = resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata);
|
||||
|
||||
if (txt) {
|
||||
const wrap = document.createElement('div');
|
||||
@@ -2186,6 +2197,9 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
b.className = 'body';
|
||||
|
||||
let text = markdownModule.squashOutsideCode(stripToolBlocks(textRaw || ''));
|
||||
if (role === 'assistant') {
|
||||
text = resolveDocumentPlaceholderLinks(text, metadata);
|
||||
}
|
||||
|
||||
// For user messages, pull out vision-model image descriptions ([Image: name]\n
|
||||
// <multi-line desc>) into a collapsible "image description" section. Done for
|
||||
|
||||
@@ -577,6 +577,7 @@ export async function _hwfitFetch(fresh = false) {
|
||||
const _sig = _scanSig();
|
||||
const _cached = fresh ? null : _readScanCache(_sig);
|
||||
const wp = spinnerModule.createWhirlpool(18);
|
||||
const _paintedFromCache = !!_cached;
|
||||
if (_cached) {
|
||||
// Tag the restored cache with its host too (scan-sig keys cache per
|
||||
// host, so a hit here is always for the current remoteHost).
|
||||
@@ -635,6 +636,10 @@ export async function _hwfitFetch(fresh = false) {
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (_paintedFromCache) {
|
||||
try { wp.destroy(); } catch {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sortBy = document.getElementById('hwfit-sort')?.value || 'newest';
|
||||
const quantPref = document.getElementById('hwfit-quant')?.value || '';
|
||||
@@ -1958,16 +1963,17 @@ export function _hwfitInit() {
|
||||
dot.className = 'cookbook-srv-status testing';
|
||||
dot.title = 'Testing SSH…';
|
||||
setMsg('Testing SSH...');
|
||||
const pf = port && port !== '22' ? `-p ${port} ` : '';
|
||||
const cmd = `ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new ${pf}${host} "echo ok"`;
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const res = await fetch('/api/shell/exec', {
|
||||
const res = await fetch('/api/cookbook/test-ssh', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: cmd, timeout: 8 }),
|
||||
body: JSON.stringify({ host, ssh_port: port || undefined }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const ms = Date.now() - t0;
|
||||
const out = (data.stdout || '').trim();
|
||||
if (data.exit_code === 0 && out.startsWith('ok')) {
|
||||
@@ -1976,7 +1982,7 @@ export function _hwfitInit() {
|
||||
setMsg(`Connected · ${ms} ms`, 'var(--green,#50fa7b)');
|
||||
} else {
|
||||
dot.className = 'cookbook-srv-status fail';
|
||||
const err = (data.stderr || data.stdout || `exit ${data.exit_code}`).toString().trim().slice(0, 240);
|
||||
const err = (data.stderr || data.stdout || (data.exit_code == null ? 'no exit code' : `exit ${data.exit_code}`)).toString().trim().slice(0, 240);
|
||||
dot.title = `SSH failed: ${err}`;
|
||||
setMsg(`Failed · ${err}`, 'var(--red,#e06c75)');
|
||||
}
|
||||
|
||||
@@ -873,8 +873,9 @@ async function _fetchDependencies() {
|
||||
let _spin = null;
|
||||
try {
|
||||
const sp = (await import('./spinner.js')).default;
|
||||
_spin = sp.createWhirlpool(28);
|
||||
_spin.element.style.cssText = 'margin:24px auto 0;display:block;';
|
||||
_spin = sp.createWhirlpool(22);
|
||||
_spin.element.classList.add('cookbook-section-loading-wp');
|
||||
_spin.element.style.cssText = 'margin:24px auto 0;display:block;width:22px;height:22px;';
|
||||
list.appendChild(_spin.element);
|
||||
const label = document.createElement('div');
|
||||
label.className = 'hwfit-loading';
|
||||
@@ -1788,7 +1789,7 @@ function _wireTabEvents(body) {
|
||||
|
||||
const scanBtn = document.getElementById('hwfit-cache-scan');
|
||||
if (scanBtn) {
|
||||
scanBtn.addEventListener('click', () => _fetchCachedModels());
|
||||
scanBtn.addEventListener('click', () => _fetchCachedModels(true));
|
||||
}
|
||||
|
||||
const editDirsLink = document.querySelector('.cookbook-serve-dir-edit');
|
||||
@@ -2286,8 +2287,9 @@ function _wireTabEvents(body) {
|
||||
hfList.innerHTML = '';
|
||||
try {
|
||||
const sp = (await import('./spinner.js')).default;
|
||||
const _spin = sp.createWhirlpool(28);
|
||||
_spin.element.style.cssText = 'margin:24px auto 0;display:block;';
|
||||
const _spin = sp.createWhirlpool(22);
|
||||
_spin.element.classList.add('cookbook-section-loading-wp');
|
||||
_spin.element.style.cssText = 'margin:24px auto 0;display:block;width:22px;height:22px;';
|
||||
hfList.appendChild(_spin.element);
|
||||
const lbl = document.createElement('div');
|
||||
lbl.className = 'hwfit-loading';
|
||||
|
||||
+114
-75
@@ -45,6 +45,30 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
|
||||
|
||||
let _cachedAllModels = [];
|
||||
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1';
|
||||
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
|
||||
|
||||
function _readCachedModelScan(sig) {
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
|
||||
const entry = all[sig];
|
||||
if (entry && Date.now() - (entry.ts || 0) < _CACHED_MODELS_SCAN_TTL) return entry.data || null;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _writeCachedModelScan(sig, data) {
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
|
||||
all[sig] = { ts: Date.now(), data };
|
||||
const keys = Object.keys(all);
|
||||
if (keys.length > 12) {
|
||||
keys.sort((a, b) => (all[a].ts || 0) - (all[b].ts || 0));
|
||||
for (const k of keys.slice(0, keys.length - 12)) delete all[k];
|
||||
}
|
||||
localStorage.setItem(_CACHED_MODELS_SCAN_KEY, JSON.stringify(all));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function _loadServeFavorites() {
|
||||
try {
|
||||
@@ -3563,12 +3587,91 @@ export async function openServePanelForRepo(repo, fields) {
|
||||
|
||||
// ── Fetch cached models from server ──
|
||||
|
||||
export async function _fetchCachedModels() {
|
||||
function _renderCachedModelsData(list, data, host) {
|
||||
// CHANGELOG: 'ready' already excludes partial downloads;
|
||||
// show every complete model regardless of size/backend.
|
||||
const ready = (data.models || []).filter(m => m.status === 'ready');
|
||||
|
||||
const downloading = (data.models || []).filter(m => m.status === 'downloading');
|
||||
const allModels = [...ready, ...downloading];
|
||||
_cachedAllModels = allModels;
|
||||
|
||||
if (!allModels.length) {
|
||||
if (!host) {
|
||||
list.innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:6px;text-align:center;"><div>No cached models found</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Docker Local uses Odysseus’s cache in <code>data/huggingface</code>. Download a model here, or copy an existing host HuggingFace cache into that folder once.</div></div>';
|
||||
} else {
|
||||
list.innerHTML = '<div class="hwfit-loading">No cached models found</div>';
|
||||
}
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
if (tagContainer) tagContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-detect type + family tags
|
||||
const _tagMap = {};
|
||||
const _familyMap = {};
|
||||
const _families = [
|
||||
[/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'],
|
||||
[/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'],
|
||||
[/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'],
|
||||
[/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'],
|
||||
[/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'],
|
||||
[/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'],
|
||||
];
|
||||
for (const m of allModels) {
|
||||
const n = (m.repo_id || '').toLowerCase();
|
||||
let tag = 'other';
|
||||
if (m.backend === 'ollama' || m.is_ollama) tag = 'llm';
|
||||
else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image';
|
||||
else if (/whisper|stt|asr/i.test(n)) tag = 'stt';
|
||||
else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts';
|
||||
else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding';
|
||||
else if (/lora|adapter/i.test(n)) tag = 'lora';
|
||||
else tag = 'llm';
|
||||
m._tag = tag;
|
||||
_tagMap[tag] = (_tagMap[tag] || 0) + 1;
|
||||
m._family = '';
|
||||
for (const [re, fam] of _families) {
|
||||
if (re.test(n)) { m._family = fam; _familyMap[fam] = (_familyMap[fam] || 0) + 1; break; }
|
||||
}
|
||||
if ((m.backend === 'ollama' || m.is_ollama) && !m._family) {
|
||||
m._family = 'ollama';
|
||||
_familyMap.ollama = (_familyMap.ollama || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Render tag chips
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
if (tagContainer) {
|
||||
const tagOrder = ['llm', 'image', 'lora', 'embedding', 'tts', 'stt', 'other'];
|
||||
let tagHtml = `<button class="memory-cat-chip active" data-serve-tag="">All (${allModels.length})</button>`;
|
||||
for (const t of tagOrder) {
|
||||
if (!_tagMap[t]) continue;
|
||||
tagHtml += `<button class="memory-cat-chip" data-serve-tag="${t}">${t} (${_tagMap[t]})</button>`;
|
||||
}
|
||||
const sortedFamilies = Object.entries(_familyMap).sort((a, b) => b[1] - a[1]);
|
||||
if (sortedFamilies.length) {
|
||||
for (const [fam, count] of sortedFamilies) {
|
||||
const logo = providerLogo(fam);
|
||||
const logoHtml = logo ? `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:2px;opacity:0.6;">${logo}</span>` : '';
|
||||
tagHtml += `<button class="memory-cat-chip" data-serve-tag="fam:${fam}">${logoHtml}${fam} (${count})</button>`;
|
||||
}
|
||||
}
|
||||
tagContainer.innerHTML = tagHtml;
|
||||
}
|
||||
|
||||
_rerenderCachedModels();
|
||||
}
|
||||
|
||||
export async function _fetchCachedModels(fresh = false) {
|
||||
const list = document.getElementById('hwfit-cached-list');
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
const _dlWp = spinnerModule.createWhirlpool(18);
|
||||
const _dlWp = spinnerModule.createWhirlpool(22);
|
||||
_dlWp.element.classList.add('cookbook-section-loading-wp');
|
||||
_dlWp.element.style.width = '22px';
|
||||
_dlWp.element.style.height = '22px';
|
||||
const _dlWrap = document.createElement('div');
|
||||
_dlWrap.className = 'hwfit-loading';
|
||||
_dlWrap.style.cssText = 'flex-direction:column;gap:6px;';
|
||||
@@ -3630,6 +3733,13 @@ export async function _fetchCachedModels() {
|
||||
if (host) { qp.set('host', host); const _sp4 = _getPort(host); if (_sp4) qp.set('ssh_port', _sp4); const _plat = _getPlatform(host); if (_plat) qp.set('platform', _plat); }
|
||||
if (modelDirs.length) qp.set('model_dir', modelDirs.join(','));
|
||||
const params = qp.toString() ? `?${qp}` : '';
|
||||
const scanSig = params || 'local';
|
||||
const cached = fresh ? null : _readCachedModelScan(scanSig);
|
||||
if (cached) {
|
||||
_dlWp.destroy();
|
||||
_renderCachedModelsData(list, cached, host);
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/model/cached${params}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
@@ -3644,80 +3754,9 @@ export async function _fetchCachedModels() {
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}${msg ? `: ${msg}` : ''}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
_writeCachedModelScan(scanSig, data);
|
||||
_dlWp.destroy();
|
||||
|
||||
// CHANGELOG: 'ready' already excludes partial downloads;
|
||||
// show every complete model regardless of size/backend.
|
||||
const ready = data.models.filter(m => m.status === 'ready');
|
||||
|
||||
const downloading = data.models.filter(m => m.status === 'downloading');
|
||||
const allModels = [...ready, ...downloading];
|
||||
_cachedAllModels = allModels;
|
||||
|
||||
if (!allModels.length) {
|
||||
if (!host) {
|
||||
list.innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:6px;text-align:center;"><div>No cached models found</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Docker Local uses Odysseus’s cache in <code>data/huggingface</code>. Download a model here, or copy an existing host HuggingFace cache into that folder once.</div></div>';
|
||||
} else {
|
||||
list.innerHTML = '<div class="hwfit-loading">No cached models found</div>';
|
||||
}
|
||||
document.getElementById('serve-tags').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-detect type + family tags
|
||||
const _tagMap = {};
|
||||
const _familyMap = {};
|
||||
const _families = [
|
||||
[/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'],
|
||||
[/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'],
|
||||
[/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'],
|
||||
[/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'],
|
||||
[/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'],
|
||||
[/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'],
|
||||
];
|
||||
for (const m of allModels) {
|
||||
const n = (m.repo_id || '').toLowerCase();
|
||||
let tag = 'other';
|
||||
if (m.backend === 'ollama' || m.is_ollama) tag = 'llm';
|
||||
else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image';
|
||||
else if (/whisper|stt|asr/i.test(n)) tag = 'stt';
|
||||
else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts';
|
||||
else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding';
|
||||
else if (/lora|adapter/i.test(n)) tag = 'lora';
|
||||
else tag = 'llm';
|
||||
m._tag = tag;
|
||||
_tagMap[tag] = (_tagMap[tag] || 0) + 1;
|
||||
m._family = '';
|
||||
for (const [re, fam] of _families) {
|
||||
if (re.test(n)) { m._family = fam; _familyMap[fam] = (_familyMap[fam] || 0) + 1; break; }
|
||||
}
|
||||
if ((m.backend === 'ollama' || m.is_ollama) && !m._family) {
|
||||
m._family = 'ollama';
|
||||
_familyMap.ollama = (_familyMap.ollama || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Render tag chips
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
if (tagContainer) {
|
||||
const tagOrder = ['llm', 'image', 'lora', 'embedding', 'tts', 'stt', 'other'];
|
||||
let tagHtml = `<button class="memory-cat-chip active" data-serve-tag="">All (${allModels.length})</button>`;
|
||||
for (const t of tagOrder) {
|
||||
if (!_tagMap[t]) continue;
|
||||
tagHtml += `<button class="memory-cat-chip" data-serve-tag="${t}">${t} (${_tagMap[t]})</button>`;
|
||||
}
|
||||
const sortedFamilies = Object.entries(_familyMap).sort((a, b) => b[1] - a[1]);
|
||||
if (sortedFamilies.length) {
|
||||
for (const [fam, count] of sortedFamilies) {
|
||||
const logo = providerLogo(fam);
|
||||
const logoHtml = logo ? `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:2px;opacity:0.6;">${logo}</span>` : '';
|
||||
tagHtml += `<button class="memory-cat-chip" data-serve-tag="fam:${fam}">${logoHtml}${fam} (${count})</button>`;
|
||||
}
|
||||
}
|
||||
tagContainer.innerHTML = tagHtml;
|
||||
}
|
||||
|
||||
_rerenderCachedModels();
|
||||
_renderCachedModelsData(list, data, host);
|
||||
} catch (e) {
|
||||
_dlWp.destroy();
|
||||
list.innerHTML = `<div class="hwfit-loading">Failed: ${esc(e.message)}</div>`;
|
||||
|
||||
+420
-82
@@ -31,6 +31,11 @@ import * as Modals from './modalManager.js';
|
||||
let _emailAccountsCache = null;
|
||||
let _emailAccountsCacheAt = 0;
|
||||
let _emailHeaderManualExpandUntil = 0;
|
||||
let _emailStreamAnimFrame = null;
|
||||
let _emailStreamRenderedBody = '';
|
||||
let _emailStreamTargetBody = '';
|
||||
let _emailLocalDraftDebounce = null;
|
||||
const _EMAIL_LOCAL_DRAFT_PREFIX = 'odysseus.email.replyDraft.v1:';
|
||||
|
||||
// Diff mode state
|
||||
let _diffModeActive = false;
|
||||
@@ -38,6 +43,8 @@ import * as Modals from './modalManager.js';
|
||||
let _diffNewContent = null;
|
||||
let _diffChunks = []; // [{id, oldLines, newLines, startLine, resolved, accepted}]
|
||||
let _diffUnresolvedCount = 0;
|
||||
let _mdPreviewClickTimes = [];
|
||||
let _mdPreviewHintLastAt = 0;
|
||||
|
||||
// Language auto-detection config
|
||||
const AUTO_DETECT_DELAY = 500;
|
||||
@@ -2045,9 +2052,8 @@ import * as Modals from './modalManager.js';
|
||||
|| '';
|
||||
const isForm = _isFormBackedDoc(live);
|
||||
// Footer main button: for a doc opened from an email attachment, morph the
|
||||
// Copy button into "Reply" (send the filled file back to the sender via the
|
||||
// signed-reply flow). Otherwise it's the normal Copy action. The click
|
||||
// handler branches on data-mode.
|
||||
// Save button into "Attach" (send the filled file back to the sender via
|
||||
// the signed-reply flow). Otherwise it forces a new saved version.
|
||||
const _copyBtn = document.getElementById('doc-footer-copy-btn');
|
||||
if (_copyBtn) {
|
||||
const _ad = docs.get(activeDocId);
|
||||
@@ -2056,10 +2062,10 @@ import * as Modals from './modalManager.js';
|
||||
_copyBtn.dataset.mode = 'reply';
|
||||
_copyBtn.title = 'Reply to the sender with this filled file attached';
|
||||
_copyBtn.innerHTML = '<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="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>Attach';
|
||||
} else if (!_replyable && _copyBtn.dataset.mode !== 'copy') {
|
||||
_copyBtn.dataset.mode = 'copy';
|
||||
_copyBtn.title = 'Copy document';
|
||||
_copyBtn.innerHTML = '<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"><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>Copy';
|
||||
} else if (!_replyable && _copyBtn.dataset.mode !== 'save') {
|
||||
_copyBtn.dataset.mode = 'save';
|
||||
_copyBtn.title = 'Save new version';
|
||||
_copyBtn.innerHTML = '<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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save';
|
||||
}
|
||||
}
|
||||
// Standalone Export PDF / PDF-toggle icon buttons are retired — for a
|
||||
@@ -2180,6 +2186,7 @@ import * as Modals from './modalManager.js';
|
||||
if (mdToggle) {
|
||||
mdToggle.querySelector('[data-mdview="edit"]')?.classList.toggle('active', !_mdActive);
|
||||
mdToggle.querySelector('[data-mdview="preview"]')?.classList.toggle('active', _mdActive);
|
||||
mdToggle.classList.toggle('is-preview-active', !!_mdActive);
|
||||
}
|
||||
} else if (lang === 'csv') {
|
||||
show = true;
|
||||
@@ -2206,6 +2213,16 @@ import * as Modals from './modalManager.js';
|
||||
// suppress the single morph button to avoid two redundant controls.
|
||||
if (_hasViewToggle(lang)) show = false;
|
||||
actionBtn.style.display = show ? '' : 'none';
|
||||
document.querySelectorAll('.md-toolbar-edit-only').forEach(el => {
|
||||
el.style.display = (lang === 'markdown' && _mdActive) ? 'none' : '';
|
||||
});
|
||||
const mdToolbar = document.getElementById('doc-md-toolbar');
|
||||
if (mdToolbar) {
|
||||
mdToolbar.classList.toggle('md-preview-active', lang === 'markdown' && !!_mdActive);
|
||||
mdToolbar.classList.toggle('md-write-active', lang === 'markdown' && !_mdActive);
|
||||
}
|
||||
if (_mdPreview) _mdPreview.classList.toggle('md-preview-active', lang === 'markdown' && !!_mdActive);
|
||||
if (mdToolbar && mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow);
|
||||
|
||||
// Now that the contextual buttons' visibility is settled, collapse the bar
|
||||
// if it ended up empty (the common plain-doc-on-mobile case).
|
||||
@@ -2215,20 +2232,24 @@ import * as Modals from './modalManager.js';
|
||||
// ── Email document type helpers ──
|
||||
|
||||
function _parseEmailHeader(content) {
|
||||
const empty = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', attachments: [], body: content || '' };
|
||||
const empty = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', forwardAttachments: false, attachments: [], body: content || '' };
|
||||
if (!content) return empty;
|
||||
const parts = content.split(/\n---\n/);
|
||||
if (parts.length < 2) return empty;
|
||||
const header = parts[0];
|
||||
const body = parts.slice(1).join('\n---\n');
|
||||
const fields = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', attachments: [], body: body };
|
||||
const fields = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', forwardAttachments: false, attachments: [], body: body };
|
||||
for (const line of header.split('\n')) {
|
||||
const m = line.match(/^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Attachments):\s*(.*)$/i);
|
||||
const m = line.match(/^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Forward-Attachments|X-Attachments):\s*(.*)$/i);
|
||||
if (m) {
|
||||
let key = m[1].toLowerCase();
|
||||
if (key === 'in-reply-to') key = 'inReplyTo';
|
||||
else if (key === 'x-source-uid') key = 'sourceUid';
|
||||
else if (key === 'x-source-folder') key = 'sourceFolder';
|
||||
else if (key === 'x-forward-attachments') {
|
||||
fields.forwardAttachments = /^(1|true|yes)$/i.test((m[2] || '').trim());
|
||||
continue;
|
||||
}
|
||||
else if (key === 'x-attachments') {
|
||||
fields.attachments = m[2].trim().split('|').map(a => {
|
||||
const [index, filename, size] = a.split(':');
|
||||
@@ -2254,6 +2275,95 @@ import * as Modals from './modalManager.js';
|
||||
return header + '\n---\n' + body;
|
||||
}
|
||||
|
||||
function _emailLocalDraftKey(sourceUid, sourceFolder, inReplyTo) {
|
||||
const uid = String(sourceUid || '').trim();
|
||||
if (!uid) return '';
|
||||
const folder = String(sourceFolder || 'INBOX').trim() || 'INBOX';
|
||||
const msg = String(inReplyTo || '').trim();
|
||||
return _EMAIL_LOCAL_DRAFT_PREFIX + encodeURIComponent(`${folder}|${uid}|${msg}`);
|
||||
}
|
||||
|
||||
function _loadEmailLocalDraft(fields) {
|
||||
const key = _emailLocalDraftKey(fields?.sourceUid, fields?.sourceFolder, fields?.inReplyTo);
|
||||
if (!key) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const draft = JSON.parse(raw);
|
||||
if (!draft || typeof draft !== 'object') return null;
|
||||
const updatedAt = Number(draft.updatedAt || 0);
|
||||
if (updatedAt && Date.now() - updatedAt > 45 * 24 * 60 * 60 * 1000) {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return draft;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _emailFieldsWithLocalDraft(fields) {
|
||||
const draft = _loadEmailLocalDraft(fields);
|
||||
if (!draft) return fields;
|
||||
return {
|
||||
...fields,
|
||||
to: draft.to ?? fields.to,
|
||||
cc: draft.cc ?? fields.cc,
|
||||
bcc: draft.bcc ?? fields.bcc,
|
||||
subject: draft.subject ?? fields.subject,
|
||||
inReplyTo: draft.inReplyTo ?? fields.inReplyTo,
|
||||
references: draft.references ?? fields.references,
|
||||
sourceUid: draft.sourceUid ?? fields.sourceUid,
|
||||
sourceFolder: draft.sourceFolder ?? fields.sourceFolder,
|
||||
body: draft.body ?? fields.body,
|
||||
};
|
||||
}
|
||||
|
||||
function _persistEmailLocalDraftNow() {
|
||||
const doc = activeDocId && docs.get(activeDocId);
|
||||
if (!doc || doc.language !== 'email') return;
|
||||
const sourceUid = document.getElementById('doc-email-source-uid')?.value || '';
|
||||
const sourceFolder = document.getElementById('doc-email-source-folder')?.value || 'INBOX';
|
||||
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value || '';
|
||||
const key = _emailLocalDraftKey(sourceUid, sourceFolder, inReplyTo);
|
||||
if (!key) return;
|
||||
const rich = document.getElementById('doc-email-richbody');
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
const body = (rich && rich.style.display !== 'none') ? rich.innerHTML : (textarea?.value || '');
|
||||
const payload = {
|
||||
to: document.getElementById('doc-email-to')?.value || '',
|
||||
cc: document.getElementById('doc-email-cc')?.value || '',
|
||||
bcc: document.getElementById('doc-email-bcc')?.value || '',
|
||||
subject: document.getElementById('doc-email-subject')?.value || '',
|
||||
inReplyTo,
|
||||
references: document.getElementById('doc-email-references')?.value || '',
|
||||
sourceUid,
|
||||
sourceFolder,
|
||||
body,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
try { localStorage.setItem(key, JSON.stringify(payload)); } catch (_) {}
|
||||
}
|
||||
|
||||
function _persistEmailLocalDraftSoon() {
|
||||
clearTimeout(_emailLocalDraftDebounce);
|
||||
_emailLocalDraftDebounce = setTimeout(_persistEmailLocalDraftNow, 250);
|
||||
}
|
||||
|
||||
function _clearEmailLocalDraft(sourceUid, sourceFolder, inReplyTo) {
|
||||
const key = _emailLocalDraftKey(sourceUid, sourceFolder, inReplyTo);
|
||||
if (!key) return;
|
||||
try { localStorage.removeItem(key); } catch (_) {}
|
||||
}
|
||||
|
||||
function _clearCurrentEmailLocalDraft() {
|
||||
_clearEmailLocalDraft(
|
||||
document.getElementById('doc-email-source-uid')?.value || '',
|
||||
document.getElementById('doc-email-source-folder')?.value || 'INBOX',
|
||||
document.getElementById('doc-email-in-reply-to')?.value || '',
|
||||
);
|
||||
}
|
||||
|
||||
// ── WYSIWYG email body helpers ──
|
||||
function _emailBodyToHtml(text) {
|
||||
const t = (text || '').trim();
|
||||
@@ -2282,7 +2392,10 @@ import * as Modals from './modalManager.js';
|
||||
function _wireEmailRichbody(rich) {
|
||||
if (rich._wired) { _syncEmailRichbody(rich); return; }
|
||||
rich._wired = true;
|
||||
rich.addEventListener('input', () => _syncEmailRichbody(rich));
|
||||
rich.addEventListener('input', () => {
|
||||
_syncEmailRichbody(rich);
|
||||
_persistEmailLocalDraftSoon();
|
||||
});
|
||||
// Highlight toolbar buttons (B / I / S, headings, lists) when the caret
|
||||
// sits inside formatted text. queryCommandState reflects the live
|
||||
// selection — we just translate that into .is-active classes the CSS
|
||||
@@ -2374,6 +2487,53 @@ import * as Modals from './modalManager.js';
|
||||
});
|
||||
}
|
||||
|
||||
function _renderStreamingEmailBody(body, { immediate = false } = {}) {
|
||||
const rich = document.getElementById('doc-email-richbody');
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (!rich) return;
|
||||
|
||||
_emailStreamTargetBody = body || '';
|
||||
if (!_emailStreamRenderedBody && textarea && textarea.value) {
|
||||
_emailStreamRenderedBody = textarea.value;
|
||||
}
|
||||
|
||||
const applyBody = (value) => {
|
||||
if (textarea) {
|
||||
textarea.value = value;
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
}
|
||||
rich.innerHTML = _emailBodyToHtml(value);
|
||||
rich.scrollTop = rich.scrollHeight;
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
if (_emailStreamAnimFrame) cancelAnimationFrame(_emailStreamAnimFrame);
|
||||
_emailStreamAnimFrame = null;
|
||||
_emailStreamRenderedBody = _emailStreamTargetBody;
|
||||
applyBody(_emailStreamRenderedBody);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_emailStreamTargetBody.length < _emailStreamRenderedBody.length ||
|
||||
!_emailStreamTargetBody.startsWith(_emailStreamRenderedBody)) {
|
||||
_emailStreamRenderedBody = '';
|
||||
}
|
||||
|
||||
if (_emailStreamAnimFrame) return;
|
||||
const tick = () => {
|
||||
const remaining = _emailStreamTargetBody.length - _emailStreamRenderedBody.length;
|
||||
if (remaining <= 0) {
|
||||
_emailStreamAnimFrame = null;
|
||||
return;
|
||||
}
|
||||
const step = Math.max(1, Math.min(8, Math.ceil(remaining / 18)));
|
||||
_emailStreamRenderedBody = _emailStreamTargetBody.slice(0, _emailStreamRenderedBody.length + step);
|
||||
applyBody(_emailStreamRenderedBody);
|
||||
_emailStreamAnimFrame = requestAnimationFrame(tick);
|
||||
};
|
||||
_emailStreamAnimFrame = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function _stripEmailReplyQuoteText(text) {
|
||||
const original = String(text || '');
|
||||
if (!original) return { body: '', stripped: false };
|
||||
@@ -2397,6 +2557,7 @@ import * as Modals from './modalManager.js';
|
||||
syncHighlighting();
|
||||
const rich = _emailRichbodyActive();
|
||||
if (rich) rich.innerHTML = _emailBodyToHtml(textarea.value);
|
||||
_persistEmailLocalDraftSoon();
|
||||
}
|
||||
|
||||
async function _streamEmailBodyText(textarea, value) {
|
||||
@@ -2411,6 +2572,7 @@ import * as Modals from './modalManager.js';
|
||||
const next = finalText.slice(0, i + chunk);
|
||||
textarea.value = next;
|
||||
if (rich) rich.innerHTML = _emailBodyToHtml(next);
|
||||
_persistEmailLocalDraftSoon();
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
_setEmailBodyText(textarea, finalText);
|
||||
@@ -2509,7 +2671,8 @@ import * as Modals from './modalManager.js';
|
||||
document.getElementById('doc-editor-textarea')?.classList.add('email-mode');
|
||||
document.getElementById('doc-editor-code')?.classList.add('email-mode');
|
||||
document.getElementById('doc-editor-highlight')?.classList.add('email-mode');
|
||||
const fields = _parseEmailHeader(doc.content || '');
|
||||
let fields = _parseEmailHeader(doc.content || '');
|
||||
fields = _emailFieldsWithLocalDraft(fields);
|
||||
const toInput = document.getElementById('doc-email-to');
|
||||
const subjectInput = document.getElementById('doc-email-subject');
|
||||
const inReplyTo = document.getElementById('doc-email-in-reply-to');
|
||||
@@ -2635,6 +2798,10 @@ import * as Modals from './modalManager.js';
|
||||
if (_rich && _srcWrap) {
|
||||
_srcWrap.style.display = 'none';
|
||||
_rich.style.display = '';
|
||||
if (_emailStreamAnimFrame) cancelAnimationFrame(_emailStreamAnimFrame);
|
||||
_emailStreamAnimFrame = null;
|
||||
_emailStreamRenderedBody = fields.body || '';
|
||||
_emailStreamTargetBody = fields.body || '';
|
||||
_rich.innerHTML = _emailBodyToHtml(fields.body);
|
||||
_wireEmailRichbody(_rich);
|
||||
setTimeout(() => {
|
||||
@@ -2660,6 +2827,49 @@ import * as Modals from './modalManager.js';
|
||||
if (bccRow) bccRow.style.display = hasCcBcc ? '' : 'none';
|
||||
if (ccToggle) ccToggle.style.display = hasCcBcc ? 'none' : '';
|
||||
_syncEmailHeaderSummary();
|
||||
_stageForwardedSourceAttachments(fields).catch(err => console.error('Forward attachment staging failed:', err));
|
||||
}
|
||||
|
||||
function _syncStreamingEmailFields(doc) {
|
||||
if (!doc) return;
|
||||
const fields = _parseEmailHeader(doc.content || '');
|
||||
const rich = document.getElementById('doc-email-richbody');
|
||||
const srcWrap = document.getElementById('doc-editor-wrap');
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (!rich || rich.style.display === 'none') {
|
||||
_showEmailFields(doc);
|
||||
return;
|
||||
}
|
||||
|
||||
const setValue = (id, value) => {
|
||||
const el = document.getElementById(id);
|
||||
const next = value || '';
|
||||
if (el && el.value !== next) el.value = next;
|
||||
};
|
||||
setValue('doc-email-to', fields.to);
|
||||
setValue('doc-email-subject', fields.subject);
|
||||
setValue('doc-email-in-reply-to', fields.inReplyTo);
|
||||
setValue('doc-email-references', fields.references);
|
||||
setValue('doc-email-source-uid', fields.sourceUid || '');
|
||||
setValue('doc-email-source-folder', fields.sourceFolder || '');
|
||||
setValue('doc-email-cc', fields.cc || '');
|
||||
setValue('doc-email-bcc', fields.bcc || '');
|
||||
|
||||
const unreadBtn = document.getElementById('doc-email-unread-btn');
|
||||
if (unreadBtn) unreadBtn.style.display = fields.sourceUid ? '' : 'none';
|
||||
const ccRow = document.getElementById('doc-email-cc-row');
|
||||
const bccRow = document.getElementById('doc-email-bcc-row');
|
||||
const ccToggle = document.getElementById('doc-email-show-cc');
|
||||
const hasCcBcc = !!(fields.cc || fields.bcc);
|
||||
if (ccRow) ccRow.style.display = hasCcBcc ? '' : 'none';
|
||||
if (bccRow) bccRow.style.display = hasCcBcc ? '' : 'none';
|
||||
if (ccToggle) ccToggle.style.display = hasCcBcc ? 'none' : '';
|
||||
|
||||
if (srcWrap) srcWrap.style.display = 'none';
|
||||
rich.style.display = '';
|
||||
_renderStreamingEmailBody(fields.body || '');
|
||||
if (doc._originalBody == null) doc._originalBody = fields.body || '';
|
||||
_syncEmailHeaderSummary();
|
||||
}
|
||||
|
||||
async function _uploadComposeFiles(files) {
|
||||
@@ -2695,6 +2905,47 @@ import * as Modals from './modalManager.js';
|
||||
_renderComposeAttachments();
|
||||
}
|
||||
|
||||
async function _stageForwardedSourceAttachments(fields) {
|
||||
const doc = docs.get(activeDocId);
|
||||
if (!doc || doc.language !== 'email') return;
|
||||
if (!fields?.forwardAttachments || !fields.sourceUid || !Array.isArray(fields.attachments) || fields.attachments.length === 0) return;
|
||||
const sourceKey = `${fields.sourceFolder || 'INBOX'}:${fields.sourceUid}:${fields.attachments.map(a => a.index).join(',')}`;
|
||||
if (doc._forwardedAttachmentSourceKey === sourceKey) return;
|
||||
doc._forwardedAttachmentSourceKey = sourceKey;
|
||||
if (!doc._composeAtts) doc._composeAtts = [];
|
||||
const existingForwarded = new Set(doc._composeAtts.filter(a => a.forwardedSourceKey === sourceKey).map(a => String(a.sourceIndex)));
|
||||
let added = 0;
|
||||
for (const att of fields.attachments) {
|
||||
const sourceIndex = String(att.index);
|
||||
if (existingForwarded.has(sourceIndex)) continue;
|
||||
try {
|
||||
const folderQs = encodeURIComponent(fields.sourceFolder || 'INBOX');
|
||||
const res = await fetch(`${API_BASE}/api/email/compose-from-attachment/${encodeURIComponent(fields.sourceUid)}/${encodeURIComponent(att.index)}?folder=${folderQs}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'failed');
|
||||
doc._composeAtts.push({
|
||||
token: data.token,
|
||||
filename: data.filename || att.filename,
|
||||
size: data.size || att.size || 0,
|
||||
forwardedSourceKey: sourceKey,
|
||||
sourceIndex,
|
||||
});
|
||||
added += 1;
|
||||
} catch (err) {
|
||||
console.error('Failed to stage forwarded attachment:', err);
|
||||
if (uiModule) uiModule.showError(`Forward attachment failed: ${att.filename || 'attachment'}`);
|
||||
}
|
||||
}
|
||||
if (added) {
|
||||
_renderComposeAttachments();
|
||||
clearTimeout(_autoSaveDebounce);
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
|
||||
}
|
||||
}
|
||||
|
||||
async function _handleAttachUpload(e) {
|
||||
const files = e.target.files;
|
||||
e.target.value = ''; // reset for next upload
|
||||
@@ -3117,6 +3368,8 @@ import * as Modals from './modalManager.js';
|
||||
in_reply_to: inReplyTo || null, references: references || null,
|
||||
attachments: attachments.length > 0 ? attachments : null,
|
||||
account_id: activeAccountId,
|
||||
source_uid: sourceUid || null,
|
||||
source_folder: sourceFolder || null,
|
||||
wait_for_delivery: true,
|
||||
}),
|
||||
});
|
||||
@@ -3166,9 +3419,12 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
// Mark the source email as answered if this was a reply
|
||||
if (sourceUid) {
|
||||
fetch(`${API_BASE}/api/email/mark-answered/${sourceUid}?folder=${encodeURIComponent(sourceFolder)}`, { method: 'POST' }).catch(() => {});
|
||||
_clearEmailLocalDraft(sourceUid, sourceFolder, inReplyTo);
|
||||
const markParams = new URLSearchParams({ folder: sourceFolder });
|
||||
if (data.account_id || activeAccountId) markParams.set('account_id', data.account_id || activeAccountId);
|
||||
fetch(`${API_BASE}/api/email/mark-answered/${encodeURIComponent(sourceUid)}?${markParams.toString()}`, { method: 'POST' }).catch(() => {});
|
||||
// Tell the inbox to refresh so the answered state shows
|
||||
window.dispatchEvent(new CustomEvent('email-answered', { detail: { uid: sourceUid } }));
|
||||
window.dispatchEvent(new CustomEvent('email-answered', { detail: { uid: sourceUid, folder: sourceFolder, account_id: data.account_id || activeAccountId || null } }));
|
||||
}
|
||||
// Delete the compose document after successful send. It was usually
|
||||
// already detached from the visible tabs so sending can finish in the
|
||||
@@ -3637,6 +3893,7 @@ import * as Modals from './modalManager.js';
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (uiModule) uiModule.showToast(`Scheduled for ${new Date(localDt).toLocaleString()}`);
|
||||
_clearCurrentEmailLocalDraft();
|
||||
cleanup();
|
||||
// Close the document
|
||||
_closeWithoutDeleting(true);
|
||||
@@ -3800,20 +4057,15 @@ import * as Modals from './modalManager.js';
|
||||
|
||||
}
|
||||
|
||||
// Detach a doc from its chat session so it stops reappearing in that
|
||||
// chat: docs with content are unlinked (kept in the library), empty docs
|
||||
// are deleted. Used by both the tab × and the mobile chip-to-trash close.
|
||||
// Close a doc tab without breaking its chat association. The chat transcript
|
||||
// can contain durable document links, so detaching a non-empty doc from the
|
||||
// session makes it look like the document vanished from that chat.
|
||||
function _detachDocFromSession(docId, { toast = false } = {}) {
|
||||
const doc = docs.get(docId);
|
||||
const hasContent = doc && doc.content && doc.content.trim().length > 0;
|
||||
if (hasContent) {
|
||||
fetch(`${API_BASE}/api/document/${docId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: '' }),
|
||||
}).then(() => {
|
||||
if (toast && uiModule) uiModule.showToast('Document unlinked from session');
|
||||
}).catch(() => {});
|
||||
saveDocument({ silent: true }).catch(() => {});
|
||||
if (toast && uiModule) uiModule.showToast('Document closed');
|
||||
} else {
|
||||
fetch(`${API_BASE}/api/document/${docId}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
@@ -3921,6 +4173,7 @@ import * as Modals from './modalManager.js';
|
||||
const _rich = document.getElementById('doc-email-richbody');
|
||||
const _emailBody = (_rich && _rich.style.display !== 'none') ? _rich.innerHTML : textarea.value;
|
||||
doc.content = _buildEmailContent(to, subject, inReplyTo, references, _emailBody, sourceUid, sourceFolder, cc, bcc);
|
||||
_persistEmailLocalDraftSoon();
|
||||
} else if (textarea) {
|
||||
// Don't clobber a PDF/form-backed doc's source when the textarea is empty
|
||||
// (it's hidden behind the rendered PDF view, so its value isn't the source
|
||||
@@ -4101,8 +4354,8 @@ 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 (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>
|
||||
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source (Ctrl+Alt+M to toggle)"><span class="md-view-label">Write</span><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)"><span class="md-view-label">Preview</span><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>
|
||||
@@ -4111,20 +4364,19 @@ import * as Modals from './modalManager.js';
|
||||
<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>
|
||||
<button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" data-md="strike" title="Strikethrough"><s>S</s></button>
|
||||
<span class="md-toolbar-sep"></span>
|
||||
<button type="button" class="md-dd-toggle" data-dd="heading" title="Heading"><b>H</b><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<button type="button" class="md-dd-toggle" data-dd="list" title="List"><span style="font-variant-numeric:tabular-nums;">1.</span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<span class="md-toolbar-sep"></span>
|
||||
<button type="button" data-md="link" title="Link"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
|
||||
<button type="button" id="md-toolbar-attach-btn" class="md-toolbar-attach-btn" title="Insert image"><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="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></button>
|
||||
<button type="button" class="md-dd-toggle md-toolbar-email-hide" data-dd="code" title="Code">\`<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<button type="button" data-md="hr" title="Horizontal rule">—</button>
|
||||
<span class="md-toolbar-sep"></span>
|
||||
<span id="md-toolbar-emoji-slot"></span>
|
||||
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
|
||||
<button type="button" class="md-toolbar-edit-only" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" class="md-toolbar-edit-only" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" class="md-toolbar-edit-only" data-md="strike" title="Strikethrough"><s>S</s></button>
|
||||
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
|
||||
<button type="button" class="md-dd-toggle md-toolbar-edit-only" data-dd="heading" title="Heading"><b>H</b><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<button type="button" class="md-dd-toggle md-toolbar-edit-only" data-dd="list" title="List"><span style="font-variant-numeric:tabular-nums;">1.</span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
|
||||
<button type="button" class="md-toolbar-edit-only" data-md="link" title="Link"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
|
||||
<button type="button" id="md-toolbar-attach-btn" class="md-toolbar-attach-btn md-toolbar-edit-only" title="Insert image"><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="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></button>
|
||||
<button type="button" class="md-dd-toggle md-toolbar-email-hide md-toolbar-edit-only" data-dd="code" title="Code">\`<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
|
||||
<span id="md-toolbar-emoji-slot" class="md-toolbar-edit-only"></span>
|
||||
<span class="md-toolbar-sep md-toolbar-pdf-only" style="display:none"></span>
|
||||
<button type="button" id="doc-pdf-add-text-btn" class="md-toolbar-pdf-only" title="Add text box (then click on PDF)" style="display:none"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg></button>
|
||||
<button type="button" id="doc-pdf-add-check-btn" class="md-toolbar-pdf-only" title="Add checkmark (then click on PDF)" style="display:none"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></button>
|
||||
@@ -4179,7 +4431,7 @@ import * as Modals from './modalManager.js';
|
||||
csv / html / pdf) is the one growing to fill. -->
|
||||
<div id="doc-actions-footer" class="doc-email-actions">
|
||||
<span class="email-send-split" id="doc-copy-export-split">
|
||||
<button type="button" id="doc-footer-copy-btn" class="email-send-btn email-send-main" title="Copy document"><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"><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>Copy</button>
|
||||
<button type="button" id="doc-footer-copy-btn" class="email-send-btn email-send-main" title="Save new version" data-mode="save"><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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>
|
||||
<button type="button" id="doc-footer-export-btn" class="email-send-btn email-send-caret" title="Export as…" aria-label="Export options"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg></button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -4378,7 +4630,7 @@ import * as Modals from './modalManager.js';
|
||||
document.getElementById('doc-import-btn')?.addEventListener('click', () => openLibrary());
|
||||
document.getElementById('doc-footer-copy-btn')?.addEventListener('click', (e) => {
|
||||
if (e.currentTarget.dataset.mode === 'reply') { if (activeDocId) _sendSignedReply(activeDocId); }
|
||||
else copyDocument();
|
||||
else saveDocument({ silent: false, forceVersion: true });
|
||||
});
|
||||
document.getElementById('doc-footer-export-btn')?.addEventListener('click', (e) => showExportMenu(null, e.currentTarget.getBoundingClientRect()));
|
||||
// Mobile footer: Close the current doc + Copy its content (replaces the
|
||||
@@ -4674,7 +4926,13 @@ import * as Modals from './modalManager.js';
|
||||
});
|
||||
}
|
||||
['doc-email-to', 'doc-email-cc', 'doc-email-bcc', 'doc-email-subject'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', _syncEmailHeaderSummary);
|
||||
document.getElementById(id)?.addEventListener('input', () => {
|
||||
_syncEmailHeaderSummary();
|
||||
saveCurrentToMap();
|
||||
_persistEmailLocalDraftSoon();
|
||||
clearTimeout(_autoSaveDebounce);
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
|
||||
});
|
||||
document.getElementById(id)?.addEventListener('focus', () => _setEmailHeaderCollapsed(false, { manual: false }));
|
||||
});
|
||||
document.getElementById('doc-email-richbody')?.addEventListener('focus', _maybeAutoCollapseEmailHeader);
|
||||
@@ -4755,6 +5013,9 @@ import * as Modals from './modalManager.js';
|
||||
const ccToggle = document.getElementById('doc-email-show-cc');
|
||||
if (ccToggle) ccToggle.style.display = '';
|
||||
_syncEmailHeaderSummary();
|
||||
saveCurrentToMap();
|
||||
clearTimeout(_autoSaveDebounce);
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4793,6 +5054,7 @@ import * as Modals from './modalManager.js';
|
||||
if (wantPreview !== isPreview) toggleMarkdownPreview();
|
||||
_syncHeaderActions();
|
||||
});
|
||||
document.getElementById('doc-md-preview')?.addEventListener('click', _handleMarkdownPreviewClickHint);
|
||||
|
||||
// Unified Code / Run-or-View two-icon switch — language-aware: CSV flips
|
||||
// between code and the table view, Python/JS/etc. between code and run
|
||||
@@ -4856,6 +5118,7 @@ import * as Modals from './modalManager.js';
|
||||
_fontIdx = (_fontIdx + 1) % 3;
|
||||
_applyDocFont();
|
||||
syncHighlighting();
|
||||
_scheduleSelRerender();
|
||||
});
|
||||
|
||||
// Undo button in header
|
||||
@@ -4961,6 +5224,8 @@ import * as Modals from './modalManager.js';
|
||||
_autoTitleDebounce = setTimeout(() => autoTitleFromContent(ta.value), 600);
|
||||
clearTimeout(_autoSaveDebounce);
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 2000);
|
||||
const doc = activeDocId && docs.get(activeDocId);
|
||||
if (doc && doc.language === 'email') _persistEmailLocalDraftSoon();
|
||||
});
|
||||
ta.addEventListener('paste', (e) => {
|
||||
if (_activeDocLanguage() !== 'markdown') return;
|
||||
@@ -5727,6 +5992,29 @@ import * as Modals from './modalManager.js';
|
||||
scrollLeftBtn?.addEventListener('click', () => itemsWrap.scrollTo({ left: 0, behavior: 'smooth' }));
|
||||
scrollRightBtn?.addEventListener('click', () => itemsWrap.scrollTo({ left: itemsWrap.scrollWidth, behavior: 'smooth' }));
|
||||
itemsWrap?.addEventListener('scroll', updateScrollArrows, { passive: true });
|
||||
if (itemsWrap) {
|
||||
let swipeStartX = 0;
|
||||
let swipeStartY = 0;
|
||||
let swipeStartScroll = 0;
|
||||
itemsWrap.addEventListener('touchstart', (e) => {
|
||||
const t = e.touches && e.touches[0];
|
||||
if (!t) return;
|
||||
swipeStartX = t.clientX;
|
||||
swipeStartY = t.clientY;
|
||||
swipeStartScroll = itemsWrap.scrollLeft;
|
||||
}, { passive: true });
|
||||
itemsWrap.addEventListener('touchend', (e) => {
|
||||
const t = e.changedTouches && e.changedTouches[0];
|
||||
if (!t) return;
|
||||
const dx = t.clientX - swipeStartX;
|
||||
const dy = t.clientY - swipeStartY;
|
||||
if (Math.abs(dx) < 42 || Math.abs(dx) < Math.abs(dy) * 1.4) return;
|
||||
const maxScroll = Math.max(0, itemsWrap.scrollWidth - itemsWrap.clientWidth);
|
||||
const page = Math.max(90, Math.round(itemsWrap.clientWidth * 0.75));
|
||||
const nextLeft = Math.max(0, Math.min(maxScroll, swipeStartScroll - Math.sign(dx) * page));
|
||||
itemsWrap.scrollTo({ left: nextLeft, behavior: 'smooth' });
|
||||
}, { passive: true });
|
||||
}
|
||||
if (window.ResizeObserver && itemsWrap) {
|
||||
new ResizeObserver(updateScrollArrows).observe(itemsWrap);
|
||||
}
|
||||
@@ -6909,39 +7197,14 @@ import * as Modals from './modalManager.js';
|
||||
_selResizeObserver.observe(ta);
|
||||
}
|
||||
|
||||
// Detect whether the textarea is currently wrapping any line. If
|
||||
// every logical line fits on one visual row, the overlay positions
|
||||
// are exact and pinned selections are safe regardless of fullscreen
|
||||
// state. We compute rendered-row-count from scrollHeight/line-height
|
||||
// and compare against the number of \n-separated lines.
|
||||
function _textareaWraps(ta) {
|
||||
if (!ta) return false;
|
||||
const style = getComputedStyle(ta);
|
||||
const lh = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.45);
|
||||
if (!lh) return false;
|
||||
const padTop = parseFloat(style.paddingTop) || 0;
|
||||
const padBottom = parseFloat(style.paddingBottom) || 0;
|
||||
const renderedRows = Math.round((ta.scrollHeight - padTop - padBottom) / lh);
|
||||
const logicalLines = (ta.value || '').split('\n').length;
|
||||
return renderedRows > logicalLines;
|
||||
}
|
||||
|
||||
/** Update selection tracking, show badge + persistent highlight.
|
||||
* Each new selection is added (pinned). Click without selecting to clear all. */
|
||||
function updateSelectionState() {
|
||||
// Pinned selections are safe whenever the overlay measurement can
|
||||
// be exact. That holds in two cases: (1) fullscreen — width is
|
||||
// stable, or (2) no line wrapping — every logical \n-line fits on
|
||||
// one visual row, so character-precise mirror measurement isn't
|
||||
// needed. Outside both cases, panel resizes / wrap shifts make
|
||||
// overlays drift, so we no-op.
|
||||
const _pane = document.querySelector('.doc-editor-pane');
|
||||
const _isFs = !!(_pane && _pane.classList.contains('doc-fullscreen'));
|
||||
const _ta0 = document.getElementById('doc-editor-textarea');
|
||||
if (!_isFs && _textareaWraps(_ta0)) {
|
||||
if (_selections.length) clearSelection();
|
||||
return;
|
||||
}
|
||||
// The mirror measurement below uses the textarea's live computed metrics,
|
||||
// so pinned selections remain valid with wrapped lines, larger font sizes,
|
||||
// mobile widths, and non-fullscreen panes. Older code disabled selection
|
||||
// whenever wrapping was detected; increasing the document font made that
|
||||
// path fire constantly, so selecting text appeared to stop working.
|
||||
_ensureSelResizeObserver();
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (!textarea) return;
|
||||
@@ -7623,6 +7886,7 @@ import * as Modals from './modalManager.js';
|
||||
_renderDiffOverlay(entries);
|
||||
_renderDiffToolbar();
|
||||
_renderDiffGutter();
|
||||
requestAnimationFrame(() => _scrollToDiffChunk(_diffChunks[0]?.id));
|
||||
|
||||
// Update header button
|
||||
const diffBtn = document.getElementById('doc-diff-toggle-btn');
|
||||
@@ -7782,6 +8046,20 @@ import * as Modals from './modalManager.js';
|
||||
el.textContent = `${resolved} / ${_diffChunks.length} changes resolved`;
|
||||
}
|
||||
|
||||
function _scrollToDiffChunk(chunkId) {
|
||||
if (chunkId == null) return;
|
||||
const firstEl = document.querySelector(`[data-chunk-id="${chunkId}"]`);
|
||||
const highlight = document.getElementById('doc-editor-highlight');
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (!firstEl || !highlight) return;
|
||||
const target = Math.max(0, firstEl.offsetTop - 80);
|
||||
highlight.scrollTop = target;
|
||||
if (textarea) textarea.scrollTop = target;
|
||||
const gutter = document.getElementById('doc-line-numbers');
|
||||
const lineNums = gutter && _lineNumberContentEl(gutter);
|
||||
if (lineNums) lineNums.style.transform = `translateY(${-target}px)`;
|
||||
}
|
||||
|
||||
/** Resolve a single chunk */
|
||||
function _resolveChunk(chunkId, accept) {
|
||||
const chunk = _diffChunks.find(c => c.id === chunkId);
|
||||
@@ -7811,6 +8089,9 @@ import * as Modals from './modalManager.js';
|
||||
|
||||
if (_diffUnresolvedCount === 0) {
|
||||
setTimeout(() => exitDiffMode(false), 300);
|
||||
} else {
|
||||
const nextChunk = _diffChunks.find(c => !c.resolved);
|
||||
requestAnimationFrame(() => _scrollToDiffChunk(nextChunk?.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7862,6 +8143,7 @@ import * as Modals from './modalManager.js';
|
||||
function exitDiffMode(discard) {
|
||||
if (!_diffModeActive) return;
|
||||
_diffModeActive = false;
|
||||
const acceptedAnyDiffChunk = !discard && _diffChunks.some(chunk => chunk && chunk.resolved && chunk.accepted);
|
||||
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
const codeEl = document.getElementById('doc-editor-code');
|
||||
@@ -7925,6 +8207,12 @@ import * as Modals from './modalManager.js';
|
||||
syncHighlighting();
|
||||
updateLineNumbers(textarea ? textarea.value : '');
|
||||
saveDocument({ silent: true });
|
||||
if (acceptedAnyDiffChunk) {
|
||||
const lang = ((docs.get(activeDocId)?.language) || document.getElementById('doc-language-select')?.value || '').toLowerCase();
|
||||
if (lang === 'markdown') {
|
||||
requestAnimationFrame(() => _setMarkdownPreviewActive(true, { remember: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if diff mode is active */
|
||||
@@ -8402,29 +8690,50 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
|
||||
/** Save manual edits */
|
||||
export async function saveDocument({ silent = false } = {}) {
|
||||
export async function saveDocument({ silent = false, forceVersion = false } = {}) {
|
||||
if (!activeDocId) return;
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (!textarea) return;
|
||||
const savingDocId = activeDocId;
|
||||
saveCurrentToMap();
|
||||
const localDoc = docs.get(savingDocId);
|
||||
const contentToSave = localDoc?.content ?? textarea.value;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/document/${activeDocId}`, {
|
||||
const res = await fetch(`${API_BASE}/api/document/${savingDocId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ content: textarea.value }),
|
||||
body: JSON.stringify({
|
||||
content: contentToSave,
|
||||
force_version: !!forceVersion,
|
||||
summary: forceVersion ? 'Saved version' : undefined,
|
||||
}),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
// Streaming/empty email drafts can leave a local tab pointing at a temp
|
||||
// or already-deleted document. Do not keep surfacing autosave errors for
|
||||
// a document the backend no longer knows about.
|
||||
if (docs.has(savingDocId)) docs.delete(savingDocId);
|
||||
if (activeDocId === savingDocId) {
|
||||
activeDocId = null;
|
||||
renderTabs();
|
||||
}
|
||||
_syncDocIndicator();
|
||||
if (!silent && uiModule) uiModule.showError('Document no longer exists');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`);
|
||||
const doc = await res.json();
|
||||
const badge = document.getElementById('doc-version-badge');
|
||||
if (badge) { const _v = doc.version_count || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; }
|
||||
// Update map
|
||||
if (docs.has(activeDocId)) {
|
||||
docs.get(activeDocId).version = doc.version_count || 1;
|
||||
docs.get(activeDocId).content = textarea.value;
|
||||
if (docs.has(savingDocId)) {
|
||||
docs.get(savingDocId).version = doc.version_count || 1;
|
||||
docs.get(savingDocId).content = contentToSave;
|
||||
}
|
||||
_syncDocIndicator();
|
||||
if (!silent && uiModule) uiModule.showToast('Document saved');
|
||||
if (!silent && uiModule) uiModule.showToast(forceVersion ? 'New version saved' : 'Document saved');
|
||||
} catch (e) {
|
||||
console.error('Failed to save document:', e);
|
||||
const now = Date.now();
|
||||
@@ -9471,7 +9780,7 @@ import * as Modals from './modalManager.js';
|
||||
|
||||
if (_streamDocId === activeDocId) {
|
||||
if ((doc?.language || '').toLowerCase() === 'email') {
|
||||
_showEmailFields(doc);
|
||||
_syncStreamingEmailFields(doc);
|
||||
return;
|
||||
}
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
@@ -9502,6 +9811,11 @@ import * as Modals from './modalManager.js';
|
||||
* Returns the old _streamDocId so handleDocUpdate can migrate temp→real. */
|
||||
export function streamDocFinalize() {
|
||||
const oldId = _streamDocId;
|
||||
const finishingDoc = oldId ? docs.get(oldId) : null;
|
||||
if (oldId === activeDocId && (finishingDoc?.language || '').toLowerCase() === 'email') {
|
||||
const fields = _parseEmailHeader(finishingDoc.content || '');
|
||||
_renderStreamingEmailBody(fields.body || '', { immediate: true });
|
||||
}
|
||||
_streamDocId = null;
|
||||
// Hide streaming indicator + cursor
|
||||
const indicator = document.getElementById('doc-stream-indicator');
|
||||
@@ -9520,6 +9834,27 @@ import * as Modals from './modalManager.js';
|
||||
return !!(preview && preview.style.display !== 'none');
|
||||
}
|
||||
|
||||
function _handleMarkdownPreviewClickHint() {
|
||||
if (!_isMarkdownPreviewVisible()) return;
|
||||
const lang = ((docs.get(activeDocId)?.language) || document.getElementById('doc-language-select')?.value || '').toLowerCase();
|
||||
if (lang !== 'markdown') return;
|
||||
|
||||
const now = Date.now();
|
||||
_mdPreviewClickTimes = _mdPreviewClickTimes.filter(ts => now - ts < 2500);
|
||||
_mdPreviewClickTimes.push(now);
|
||||
if (_mdPreviewClickTimes.length < 3 || now - _mdPreviewHintLastAt < 5000) return;
|
||||
|
||||
_mdPreviewHintLastAt = now;
|
||||
_mdPreviewClickTimes = [];
|
||||
if (uiModule?.showToast) {
|
||||
uiModule.showToast('Preview is read-only. Click Write to edit the document.', {
|
||||
duration: 5000,
|
||||
action: 'Write',
|
||||
onAction: () => _setMarkdownPreviewActive(false, { remember: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _refreshMarkdownPreviewIfVisible(docId, content) {
|
||||
if (!_isMarkdownPreviewVisible()) return false;
|
||||
const doc = docs.get(docId);
|
||||
@@ -9712,11 +10047,14 @@ import * as Modals from './modalManager.js';
|
||||
// Toolbar shown for every doc type — items inside self-gate on language.
|
||||
if (mdToolbar) mdToolbar.style.display = '';
|
||||
// Auto-show table view for CSV after streaming
|
||||
if (finalLang === 'csv') {
|
||||
const finalLangLower = (finalLang || '').toLowerCase();
|
||||
if (finalLangLower === 'csv') {
|
||||
requestAnimationFrame(() => {
|
||||
const csvPreview = document.getElementById('doc-csv-preview');
|
||||
if (csvPreview && csvPreview.style.display === 'none') toggleCsvPreview();
|
||||
});
|
||||
} else if (streamingId && finalLangLower === 'markdown') {
|
||||
requestAnimationFrame(() => _setMarkdownPreviewActive(true, { remember: true }));
|
||||
}
|
||||
|
||||
renderTabs();
|
||||
|
||||
+130
-24
@@ -9,11 +9,10 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
|
||||
import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
||||
import { emailApiUrl, emailAccountQuery } from './emailShared.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
const _acct = () => window.__odysseusActiveEmailAccount
|
||||
? `&account_id=${encodeURIComponent(window.__odysseusActiveEmailAccount)}`
|
||||
: '';
|
||||
const _acct = () => emailAccountQuery('&');
|
||||
|
||||
const _emailSetupHint = () => '<div style="margin-top:6px;opacity:0.72;font-size:11px;">Setup: <span style="color:var(--accent,var(--red));">Settings › Integrations</span></div>';
|
||||
|
||||
@@ -27,6 +26,59 @@ const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="c
|
||||
const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _replySeparator = '---------- Previous message ----------';
|
||||
const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']);
|
||||
|
||||
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, '-')));
|
||||
}
|
||||
|
||||
function _cleanAiReplyText(text) {
|
||||
if (!text) return '';
|
||||
@@ -69,9 +121,14 @@ 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));
|
||||
if (em) { em.is_answered = true; em.is_read = true; }
|
||||
if (em) {
|
||||
em.is_answered = true;
|
||||
em.is_read = true;
|
||||
_clearDoneResponseTagsLocal(em);
|
||||
}
|
||||
document.querySelectorAll('.email-item[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(item => {
|
||||
item.classList.remove('email-unread');
|
||||
item.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
|
||||
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
|
||||
@@ -87,10 +144,15 @@ 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
|
||||
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
|
||||
|
||||
export function init(documentModule) {
|
||||
_docModule = documentModule;
|
||||
_bindEvents();
|
||||
document.addEventListener('odysseus:email-tags-toggle', (e) => {
|
||||
_showEmailTags = e.detail?.show !== false;
|
||||
_renderList();
|
||||
});
|
||||
// Init the library popup with a callback to open emails
|
||||
initEmailLibrary({
|
||||
documentModule,
|
||||
@@ -136,6 +198,23 @@ export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply', pref
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -230,24 +309,24 @@ async function _refreshUnreadCount() {
|
||||
const dot = document.getElementById('email-unread-dot');
|
||||
if (dot && !dot._stickyState) dot.style.display = 'none';
|
||||
try {
|
||||
// Parallel: unread list + urgency state.
|
||||
const [listRes, urgRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/email/list?folder=INBOX&limit=50&filter=unread${_acct()}`),
|
||||
// Parallel: cheap unread state + urgency state.
|
||||
const [stateRes, urgRes] = await Promise.all([
|
||||
fetch(emailApiUrl('/api/email/unread-state', { folder: 'INBOX' })),
|
||||
fetch(`${API_BASE}/api/email/urgency-state`, { credentials: 'same-origin' }).catch(() => null),
|
||||
]);
|
||||
if (!listRes || !listRes.ok) return;
|
||||
const data = await listRes.json();
|
||||
if (!stateRes || !stateRes.ok) return;
|
||||
const data = await stateRes.json();
|
||||
if (!dot) return;
|
||||
|
||||
const emails = data.emails || [];
|
||||
if (emails.length === 0) {
|
||||
const unreadCount = Number(data.unread_count || 0);
|
||||
if (unreadCount <= 0) {
|
||||
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);
|
||||
const maxUid = Math.max(...emails.map(e => parseInt(e.uid, 10) || 0));
|
||||
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
|
||||
|
||||
// Only show dot if there's a new email above the threshold
|
||||
dot.style.display = maxUid > lastSeen ? '' : 'none';
|
||||
@@ -275,12 +354,11 @@ 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
|
||||
fetch(`${API_BASE}/api/email/list?folder=INBOX&limit=1${_acct()}`)
|
||||
fetch(emailApiUrl('/api/email/unread-state', { folder: 'INBOX' }))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const emails = data.emails || [];
|
||||
if (emails.length > 0) {
|
||||
const maxUid = Math.max(...emails.map(e => parseInt(e.uid, 10) || 0));
|
||||
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
|
||||
if (maxUid > 0) {
|
||||
localStorage.setItem('odysseus-email-last-seen-uid', String(maxUid));
|
||||
}
|
||||
const dot = document.getElementById('email-unread-dot');
|
||||
@@ -308,7 +386,7 @@ export async function loadEmails(append = false) {
|
||||
|
||||
try {
|
||||
const fromQS = _senderFilter ? `&from=${encodeURIComponent(_senderFilter)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}&_=${Date.now()}`);
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}`);
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
@@ -338,7 +416,8 @@ export async function loadEmails(append = false) {
|
||||
|
||||
async function loadFolders() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/folders?_=1${_acct()}`);
|
||||
const accountQS = _acct().replace(/^&/, '');
|
||||
const res = await fetch(`${API_BASE}/api/email/folders${accountQS ? `?${accountQS}` : ''}`);
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('email-folder-select');
|
||||
if (!select || !data.folders) return;
|
||||
@@ -511,12 +590,10 @@ function _createEmailItem(em) {
|
||||
? `<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>`
|
||||
: '';
|
||||
|
||||
const tags = Array.isArray(em.tags) ? em.tags : [];
|
||||
const tagPills = tags.length
|
||||
? `<span class="email-tags">${tags.map(t => `<span class="email-tag email-tag-${_esc(t)}">${_esc(t)}</span>`).join('')}</span>`
|
||||
: '';
|
||||
const tags = _showEmailTags ? _visibleEmailTagsForRender(em) : [];
|
||||
const tagPills = _emailTagGroupHtml(tags, em);
|
||||
|
||||
const spamTag = em.is_spam_verdict
|
||||
const spamTag = _showEmailTags && em.is_spam_verdict
|
||||
? `<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>`
|
||||
: '';
|
||||
|
||||
@@ -534,6 +611,30 @@ function _createEmailItem(em) {
|
||||
|
||||
// Click sender name → filter list to that sender
|
||||
const senderEl = item.querySelector('.email-sender-clickable');
|
||||
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');
|
||||
});
|
||||
});
|
||||
if (senderEl) {
|
||||
senderEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -658,7 +759,8 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
try {
|
||||
let data = preloadedData;
|
||||
if (!data) {
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`);
|
||||
const fullQS = mode === 'forward' ? '&full=1' : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}${fullQS}`);
|
||||
data = await res.json();
|
||||
}
|
||||
if (data.error) {
|
||||
@@ -752,6 +854,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
if (data.attachments && data.attachments.length > 0) {
|
||||
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
|
||||
content += `\nX-Attachments: ${attStr}`;
|
||||
if (mode === 'forward') content += `\nX-Forward-Attachments: 1`;
|
||||
}
|
||||
content += '\n---\n';
|
||||
|
||||
@@ -895,6 +998,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
} else {
|
||||
await _docModule.loadDocument(doc.id);
|
||||
}
|
||||
_bringEmailReplyDraftToFrontOnMobile();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1099,9 +1203,11 @@ async function _toggleDone(em, itemEl) {
|
||||
if (newState) em.is_read = true; // mark-done implies mark-read
|
||||
if (itemEl) {
|
||||
if (newState) {
|
||||
_clearDoneResponseTagsLocal(em);
|
||||
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());
|
||||
itemEl.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
|
||||
}
|
||||
const check = itemEl.querySelector('.email-done-check');
|
||||
if (check) check.classList.toggle('active', newState);
|
||||
|
||||
+1186
-227
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ export const state = {
|
||||
_libFilter: 'all', // all, unread, unanswered
|
||||
_libSort: 'recent', // recent, unread, favorites
|
||||
_libHasAttachments: false,
|
||||
_libShowTags: localStorage.getItem('odysseus.email.showTags') !== '0',
|
||||
_libLoading: false,
|
||||
_docModule: null,
|
||||
_onEmailClick: null,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
export function emailAccountQuery(prefix = '&') {
|
||||
const accountId = window.__odysseusActiveEmailAccount || '';
|
||||
if (!accountId) return '';
|
||||
const lead = prefix === '?' ? '?' : '&';
|
||||
return `${lead}account_id=${encodeURIComponent(accountId)}`;
|
||||
}
|
||||
|
||||
export function emailApiUrl(path, params = {}) {
|
||||
const url = new URL(`${API_BASE}${path}`);
|
||||
const accountId = window.__odysseusActiveEmailAccount || '';
|
||||
if (accountId) url.searchParams.set('account_id', accountId);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
url.searchParams.set(key, String(value));
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
+5
-1
@@ -2774,6 +2774,7 @@ function _collectFormDraft(form) {
|
||||
const d = {
|
||||
_ts: Date.now(),
|
||||
note_type: type,
|
||||
color: form.dataset.noteColor || '',
|
||||
title: form.querySelector('.note-form-title')?.value || '',
|
||||
label: form.querySelector('.note-form-label')?.value || '',
|
||||
due_date: form.querySelector('.note-form-due')?.value || null,
|
||||
@@ -2819,7 +2820,7 @@ function _applyDraftToNote(note, id) {
|
||||
const d = _loadDraft(id);
|
||||
if (_isDraftEmpty(d)) return { note, restored: false };
|
||||
const merged = { ...(note || {}) };
|
||||
['note_type', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => {
|
||||
['note_type', 'color', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => {
|
||||
if (d[k] !== undefined) merged[k] = d[k];
|
||||
});
|
||||
return { note: merged, restored: true };
|
||||
@@ -2835,6 +2836,7 @@ function _buildForm(note = null) {
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'note-form';
|
||||
form.dataset.noteColor = color || '';
|
||||
if (color && !_isBgImage(color)) form.classList.add('note-color-' + color);
|
||||
if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color));
|
||||
let currentImageUrl = _safeImgSrc(note?.image_url || '');
|
||||
@@ -3038,6 +3040,7 @@ function _buildForm(note = null) {
|
||||
// Color dots — apply to entire form immediately
|
||||
const _applyFormColor = (newColor) => {
|
||||
currentColor = newColor || '';
|
||||
form.dataset.noteColor = currentColor;
|
||||
const isBg = _isBgImage(currentColor);
|
||||
COLORS.forEach(c => { if (c.value && c.value !== 'custom') form.classList.remove('note-color-' + c.value); });
|
||||
if (currentColor && !isBg) form.classList.add('note-color-' + currentColor);
|
||||
@@ -3047,6 +3050,7 @@ function _buildForm(note = null) {
|
||||
d.classList.toggle('active', _dotIsActive(d.dataset.color, currentColor));
|
||||
d.style.background = _dotBg(d.dataset.color, currentColor);
|
||||
});
|
||||
form.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
form.querySelectorAll('.note-color-dot').forEach(dot => {
|
||||
dot.addEventListener('click', () => {
|
||||
|
||||
@@ -27,16 +27,20 @@ function _markDismissed(ids) {
|
||||
}
|
||||
|
||||
let _activePollInterval = null;
|
||||
let _activePollInFlight = false;
|
||||
let _librarySyncInFlight = false;
|
||||
let _lastLibrarySyncAt = 0;
|
||||
const _LIBRARY_SYNC_MIN_MS = 120000;
|
||||
|
||||
export function init(apiBase) {
|
||||
_apiBase = apiBase;
|
||||
_reconnectActive();
|
||||
_reconnectActive({ includeLibrary: true, forceLibrary: true });
|
||||
// Poll for active sessions periodically so research started elsewhere
|
||||
// (e.g. by the agent via trigger_research) gets adopted into the
|
||||
// sidebar — _reconnectActive only ran once at load before, so
|
||||
// agent-started jobs never appeared until a page reload.
|
||||
if (_activePollInterval) clearInterval(_activePollInterval);
|
||||
_activePollInterval = setInterval(() => { _reconnectActive(); }, 12000);
|
||||
_activePollInterval = setInterval(() => { _reconnectActive(); }, 20000);
|
||||
}
|
||||
|
||||
// Allow an immediate adopt when the chat stream signals a new research
|
||||
@@ -46,7 +50,13 @@ export function adoptSession(sessionId) {
|
||||
_reconnectActive();
|
||||
}
|
||||
|
||||
async function _reconnectActive() {
|
||||
export function refreshLibrary(options = {}) {
|
||||
return _syncLibrary(options);
|
||||
}
|
||||
|
||||
async function _reconnectActive(options = {}) {
|
||||
if (_activePollInFlight) return;
|
||||
_activePollInFlight = true;
|
||||
try {
|
||||
// Reconnect to running tasks
|
||||
const res = await fetch(`${_apiBase}/api/research/active`, { credentials: 'same-origin' });
|
||||
@@ -68,7 +78,20 @@ async function _reconnectActive() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load recent completed research from disk
|
||||
if (options.includeLibrary) await _syncLibrary({ force: !!options.forceLibrary });
|
||||
_notify();
|
||||
} catch {
|
||||
} finally {
|
||||
_activePollInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _syncLibrary(options = {}) {
|
||||
const now = Date.now();
|
||||
if (_librarySyncInFlight) return;
|
||||
if (!options.force && now - _lastLibrarySyncAt < _LIBRARY_SYNC_MIN_MS) return;
|
||||
_librarySyncInFlight = true;
|
||||
try {
|
||||
const libRes = await fetch(`${_apiBase}/api/research/library?sort=recent&limit=20`, { credentials: 'same-origin' });
|
||||
if (libRes.ok) {
|
||||
const libData = await libRes.json();
|
||||
@@ -90,9 +113,12 @@ async function _reconnectActive() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_lastLibrarySyncAt = Date.now();
|
||||
_notify();
|
||||
} catch {}
|
||||
finally {
|
||||
_librarySyncInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _parseDuration(s) {
|
||||
|
||||
@@ -297,6 +297,7 @@ export function openPanel(focusJobId) {
|
||||
_loadEndpoints().then(_restoreSavedSettings);
|
||||
_clearBadge();
|
||||
_updateResearchCount();
|
||||
jobs.refreshLibrary?.({ force: true });
|
||||
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
try { Notification.requestPermission(); } catch {}
|
||||
@@ -370,7 +371,7 @@ function _buildPanelHTML() {
|
||||
</div>
|
||||
<p class="memory-desc doclib-desc" style="margin-top:2px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||
<span>Multi-step web research with an LLM-in-the-loop agent</span>
|
||||
<span id="research-no-past-hint" style="display:none;font:inherit;opacity:1;position:static;">— past runs in <button type="button" class="research-library-link" style="background:none;border:none;padding:0;font:inherit;color:var(--accent, var(--red));cursor:pointer;text-decoration:underline;">Library, Research</button></span>
|
||||
<span id="research-no-past-hint" style="display:none;font:inherit;opacity:1;position:static;">All past research found in: <button type="button" class="research-library-link" style="background:none;border:none;padding:0;font:inherit;color:var(--accent, var(--red));cursor:pointer;text-decoration:underline;">Library, Research</button></span>
|
||||
</p>
|
||||
<textarea id="research-query" class="research-query" placeholder="${_pickResearchHint()}" rows="4"></textarea>
|
||||
<button id="research-settings-toggle" class="research-settings-toggle${chevronCls}">
|
||||
@@ -669,7 +670,7 @@ function _renderJobs() {
|
||||
const allJobs = jobs.getJobs();
|
||||
if (!allJobs.length) {
|
||||
// No empty-state text in the body — the query box above is the call to
|
||||
// action. But still surface the "All past research found in Library,
|
||||
// action. But still surface the "All past research found in: Library,
|
||||
// Research" hint under the main title, since the Past section won't
|
||||
// render to host it (this is exactly the case the dynamic hint targets).
|
||||
container.innerHTML = '';
|
||||
@@ -718,7 +719,7 @@ function _renderJobs() {
|
||||
}
|
||||
|
||||
// Dynamic Past hint: when the Past section won't render (no past items),
|
||||
// surface the "All past research found in Library, Research" line under
|
||||
// surface the "All past research found in: Library, Research" line under
|
||||
// the main Research title instead, so the link is always discoverable.
|
||||
const noPastHint = document.getElementById('research-no-past-hint');
|
||||
if (noPastHint) {
|
||||
@@ -783,7 +784,7 @@ function _renderJobs() {
|
||||
if (key === 'past') {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'research-library-hint';
|
||||
hint.innerHTML = '<span>Multi-step web research with an LLM-in-the-loop agent</span> <button type="button" class="research-library-link">Library, Research</button>';
|
||||
hint.innerHTML = '<span>All past research found in:</span> <button type="button" class="research-library-link">Library, Research</button>';
|
||||
hint.querySelector('.research-library-link').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Close the research panel first so the Library opens ABOVE it on mobile
|
||||
|
||||
+321
-53
@@ -16,8 +16,11 @@ let sessions = [];
|
||||
let currentSessionId = null;
|
||||
let _sessionNavToken = 0;
|
||||
let _skipAutoSelect = false;
|
||||
let _suppressNextSessionLoading = false;
|
||||
const HISTORY_DISPLAY_CHAR_LIMIT = 160000;
|
||||
const HISTORY_DISPLAY_TAIL_CHARS = 20000;
|
||||
const HISTORY_PAGE_LIMIT_MOBILE = 8;
|
||||
const HISTORY_PAGE_LIMIT_DESKTOP = 24;
|
||||
|
||||
const SIDEBAR_MAX_VISIBLE = 10;
|
||||
const FOLDER_MAX_VISIBLE = 5;
|
||||
@@ -28,41 +31,42 @@ let _autoCreateInProgress = false; // guard against recursive auto-create
|
||||
const _INCOGNITO_SESSIONS_KEY = 'ody-incognito-sessions'; // sessionStorage key for incognito session IDs
|
||||
const _isMac = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
const _mod = _isMac ? '⌘' : 'Ctrl';
|
||||
let _historyPager = null;
|
||||
|
||||
function _paintSessionLoading(chatHistory, label = 'Loading chat') {
|
||||
if (!chatHistory) return;
|
||||
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
|
||||
chatHistory.style.transition = '';
|
||||
chatHistory.style.opacity = '1';
|
||||
chatHistory.classList.add('no-animate');
|
||||
chatHistory.innerHTML = '';
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'session-loading-state';
|
||||
wrap.className = 'session-loading-state session-loading-skeleton';
|
||||
wrap.setAttribute('role', 'status');
|
||||
wrap.setAttribute('aria-live', 'polite');
|
||||
wrap.style.cssText = [
|
||||
'min-height:100%',
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'justify-content:center',
|
||||
'gap:10px',
|
||||
'color:var(--muted, var(--fg))',
|
||||
'opacity:0.72',
|
||||
'font-size:12px'
|
||||
].join(';');
|
||||
wrap.setAttribute('aria-label', label);
|
||||
|
||||
const spinner = spinnerModule.createWhirlpool(18);
|
||||
const text = document.createElement('span');
|
||||
text.className = 'session-loading-state-label';
|
||||
text.textContent = label;
|
||||
wrap.appendChild(spinner.element);
|
||||
wrap.appendChild(text);
|
||||
const viewportHeight = chatHistory.clientHeight || window.innerHeight || 720;
|
||||
const bubbleCount = Math.max(8, Math.min(16, Math.ceil(viewportHeight / 86)));
|
||||
for (let i = 0; i < bubbleCount; i += 1) {
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = `session-skeleton-bubble ${i % 2 ? 'is-user' : 'is-ai'}`;
|
||||
const lines = i % 4 === 1 ? 2 : (i % 4 === 3 ? 3 : 4);
|
||||
for (let j = 0; j < lines; j += 1) {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'session-skeleton-line';
|
||||
line.style.width = `${[72, 92, 58, 82][(i + j) % 4]}%`;
|
||||
bubble.appendChild(line);
|
||||
}
|
||||
wrap.appendChild(bubble);
|
||||
}
|
||||
chatHistory.appendChild(wrap);
|
||||
}
|
||||
|
||||
function _updateSessionLoading(chatHistory, label) {
|
||||
const el = chatHistory?.querySelector('.session-loading-state-label');
|
||||
if (el) el.textContent = label;
|
||||
const el = chatHistory?.querySelector('.session-loading-state');
|
||||
if (el) el.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
function _nextPaint() {
|
||||
@@ -88,6 +92,181 @@ function _displayHistoryContent(content) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function _historyPageLimit() {
|
||||
return window.innerWidth <= 768 ? HISTORY_PAGE_LIMIT_MOBILE : HISTORY_PAGE_LIMIT_DESKTOP;
|
||||
}
|
||||
|
||||
function _historyUrl(id, { limit = null, offset = null } = {}) {
|
||||
const url = new URL(`${API_BASE}/api/history/${id}`);
|
||||
if (limit != null) url.searchParams.set('limit', String(limit));
|
||||
if (offset != null) url.searchParams.set('offset', String(offset));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function _renderHistoryMessage(msg, modelName) {
|
||||
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
|
||||
let displayContent;
|
||||
if (typeof msg.content === 'string') {
|
||||
displayContent = _displayHistoryContent(msg.content);
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
displayContent = _displayHistoryContent(msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim());
|
||||
} else {
|
||||
displayContent = '';
|
||||
}
|
||||
if (msg.role === 'user') {
|
||||
const trimmed = displayContent.trim();
|
||||
if (
|
||||
trimmed === 'Continue where you left off' ||
|
||||
trimmed.startsWith('Your message was cut off.') ||
|
||||
trimmed.startsWith('Your previous response was interrupted.') ||
|
||||
displayContent.includes('[Instruction: Rewrite') ||
|
||||
displayContent.includes('[Instruction: Explain')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const docEditMatch = displayContent.match(/^In the document, edit this specific text \((lines? [\d-]+)\):\n```\n([\s\S]*?)\n```\n\nInstruction: ([\s\S]*)$/);
|
||||
if (docEditMatch) {
|
||||
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
|
||||
}
|
||||
}
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) return null;
|
||||
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg ' + (msg.role === 'user' ? 'msg-user' : 'msg-ai');
|
||||
wrap.dataset.raw = displayContent;
|
||||
if (meta?._db_id) wrap.dataset.dbId = meta._db_id;
|
||||
|
||||
const roleEl = document.createElement('div');
|
||||
roleEl.className = 'role';
|
||||
if (msg.role === 'user') {
|
||||
roleEl.textContent = 'You';
|
||||
} else {
|
||||
const pair = chatRenderer.replyModelPair ? chatRenderer.replyModelPair(modelName, meta) : {};
|
||||
const resolved = pair.actualModel || pair.requestedModel || modelName;
|
||||
roleEl.textContent = chatRenderer.modelRouteLabel
|
||||
? chatRenderer.modelRouteLabel(pair.requestedModel, resolved)
|
||||
: (resolved || 'Odysseus');
|
||||
if (chatRenderer.applyModelColor) chatRenderer.applyModelColor(roleEl, resolved);
|
||||
}
|
||||
const timestamp = meta?.timestamp;
|
||||
if (timestamp) {
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'msg-time';
|
||||
try {
|
||||
ts.textContent = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
ts.textContent = '';
|
||||
}
|
||||
roleEl.appendChild(ts);
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'body';
|
||||
body.innerHTML = markdownModule.processWithThinking(
|
||||
markdownModule.squashOutsideCode(markdownModule.renderContent(displayContent || ''))
|
||||
);
|
||||
if (msg.role === 'user' && Array.isArray(meta?.attachments) && meta.attachments.length) {
|
||||
const cards = document.createElement('div');
|
||||
cards.className = 'attach-cards history-attach-cards';
|
||||
for (const att of meta.attachments) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'attach-card history-attach-card';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'attach-card-icon';
|
||||
icon.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'attach-card-name';
|
||||
name.textContent = att.name || 'Image attached';
|
||||
const size = document.createElement('span');
|
||||
size.className = 'attach-card-size';
|
||||
size.textContent = 'image';
|
||||
card.appendChild(icon);
|
||||
card.appendChild(name);
|
||||
card.appendChild(size);
|
||||
cards.appendChild(card);
|
||||
}
|
||||
body.appendChild(cards);
|
||||
}
|
||||
|
||||
wrap.appendChild(roleEl);
|
||||
wrap.appendChild(body);
|
||||
box.appendChild(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function _clearHistoryPager() {
|
||||
const box = document.getElementById('chat-history');
|
||||
if (_historyPager?.handler && box) {
|
||||
box.removeEventListener('scroll', _historyPager.handler);
|
||||
}
|
||||
_historyPager = null;
|
||||
}
|
||||
|
||||
function _installHistoryPager(id, pageInfo, modelName) {
|
||||
const box = document.getElementById('chat-history');
|
||||
_clearHistoryPager();
|
||||
if (!box || !pageInfo || !pageInfo.has_more_before) return;
|
||||
|
||||
_historyPager = {
|
||||
sessionId: id,
|
||||
offset: Number(pageInfo.offset || 0),
|
||||
limit: Number(pageInfo.limit || _historyPageLimit()),
|
||||
loading: false,
|
||||
done: false,
|
||||
modelName,
|
||||
handler: null,
|
||||
};
|
||||
|
||||
const loadOlder = async () => {
|
||||
if (!_historyPager || _historyPager.loading || _historyPager.done) return;
|
||||
if (_historyPager.sessionId !== currentSessionId) return;
|
||||
if (box.scrollTop > 90) return;
|
||||
|
||||
const nextOffset = Math.max(0, _historyPager.offset - _historyPager.limit);
|
||||
const nextLimit = _historyPager.offset - nextOffset;
|
||||
if (nextLimit <= 0) {
|
||||
_historyPager.done = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_historyPager.loading = true;
|
||||
const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble');
|
||||
const beforeHeight = box.scrollHeight;
|
||||
try {
|
||||
const res = await fetch(_historyUrl(_historyPager.sessionId, { limit: nextLimit, offset: nextOffset }));
|
||||
const data = await res.json();
|
||||
if (!_historyPager || _historyPager.sessionId !== currentSessionId) return;
|
||||
const newEls = [];
|
||||
for (const msg of data.history || []) {
|
||||
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
|
||||
const el = _renderHistoryMessage(msg, _historyPager.modelName);
|
||||
if (el) newEls.push(el);
|
||||
}
|
||||
for (const el of newEls) {
|
||||
box.insertBefore(el, anchor || box.firstChild);
|
||||
}
|
||||
_historyPager.offset = Number(data.offset || nextOffset);
|
||||
_historyPager.done = !data.has_more_before;
|
||||
if (window.hljs) {
|
||||
newEls.forEach(el => el.querySelectorAll('pre code:not(.hljs)').forEach(block => window.hljs.highlightElement(block)));
|
||||
}
|
||||
const heightDelta = box.scrollHeight - beforeHeight;
|
||||
box.scrollTop += heightDelta;
|
||||
} catch (e) {
|
||||
console.warn('Failed to load older chat history:', e);
|
||||
} finally {
|
||||
if (_historyPager) _historyPager.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
_historyPager.handler = () => {
|
||||
if (box.scrollTop <= 90) loadOlder();
|
||||
};
|
||||
box.addEventListener('scroll', _historyPager.handler, { passive: true });
|
||||
}
|
||||
|
||||
function _getIncognitoIds() {
|
||||
try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
@@ -803,6 +982,58 @@ function createSessionItem(s) {
|
||||
return div;
|
||||
}
|
||||
|
||||
function _dateBucketLabel(value) {
|
||||
if (!value) return 'Older';
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return 'Older';
|
||||
const dayStart = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||
const today = dayStart(new Date());
|
||||
const day = dayStart(d);
|
||||
const diff = Math.round((today - day) / 86400000);
|
||||
if (diff === 0) return 'Today';
|
||||
if (diff === 1) return 'Yesterday';
|
||||
if (diff > 1 && diff < 7) return d.toLocaleDateString([], { weekday: 'long' });
|
||||
if (diff >= 365) {
|
||||
const years = Math.floor(diff / 365);
|
||||
return `${years} ${years === 1 ? 'year' : 'years'} ago`;
|
||||
}
|
||||
if (diff >= 180) return '6 months ago';
|
||||
if (diff >= 30) return `${Math.floor(diff / 30) * 30} days ago`;
|
||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||
return d.toLocaleDateString([], sameYear ? { month: 'long', day: 'numeric' } : { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function _sessionBucketDate(s) {
|
||||
return s.last_message_at || s.updated_at || s.created_at || '';
|
||||
}
|
||||
|
||||
function _createDateSectionHeader(label, kind = 'session') {
|
||||
const el = document.createElement('div');
|
||||
el.className = `date-section-header ${kind}-date-section-header`;
|
||||
el.textContent = label;
|
||||
return el;
|
||||
}
|
||||
|
||||
function _appendSessionItemsWithDateHeaders(frag, items) {
|
||||
let lastLabel = null;
|
||||
for (const s of items) {
|
||||
const label = _dateBucketLabel(_sessionBucketDate(s));
|
||||
if (label !== lastLabel) {
|
||||
frag.appendChild(_createDateSectionHeader(label, 'session'));
|
||||
lastLabel = label;
|
||||
}
|
||||
frag.appendChild(createSessionItem(s));
|
||||
}
|
||||
}
|
||||
|
||||
function _appendFavoriteSessionItems(frag, items) {
|
||||
if (!items.length) return;
|
||||
frag.appendChild(_createDateSectionHeader('Favorites', 'session'));
|
||||
for (const s of items) {
|
||||
frag.appendChild(createSessionItem(s));
|
||||
}
|
||||
}
|
||||
|
||||
let _renderRAF = null;
|
||||
export function renderSessionList() {
|
||||
// Debounce rapid re-renders within the same frame
|
||||
@@ -861,17 +1092,22 @@ function _renderSessionListImpl() {
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
// Starred still float to top
|
||||
const starred = orderedSessions.filter(s => s.is_important);
|
||||
const rest = orderedSessions.filter(s => !s.is_important);
|
||||
const allFlat = [...starred, ...rest];
|
||||
// Favorites are a global pinned block above date buckets, not just
|
||||
// promoted within the day they belong to.
|
||||
const allFlat = [
|
||||
...orderedSessions.filter(s => s.is_important),
|
||||
...orderedSessions.filter(s => !s.is_important),
|
||||
];
|
||||
|
||||
const limit = _showAllSessions ? allFlat.length : SIDEBAR_MAX_VISIBLE;
|
||||
const visible = allFlat.slice(0, limit);
|
||||
const activeIdx = allFlat.findIndex(s => s.id === currentSessionId);
|
||||
if (!_showAllSessions && activeIdx >= limit) visible.push(allFlat[activeIdx]);
|
||||
|
||||
visible.forEach(s => _frag.appendChild(createSessionItem(s)));
|
||||
const visibleFavorites = visible.filter(s => s.is_important);
|
||||
const visibleRegular = visible.filter(s => !s.is_important);
|
||||
_appendFavoriteSessionItems(_frag, visibleFavorites);
|
||||
_appendSessionItemsWithDateHeaders(_frag, visibleRegular);
|
||||
|
||||
if (allFlat.length > SIDEBAR_MAX_VISIBLE) {
|
||||
const remaining = allFlat.length - SIDEBAR_MAX_VISIBLE;
|
||||
@@ -1510,8 +1746,12 @@ export async function loadSessions() {
|
||||
}
|
||||
}
|
||||
|
||||
const suppressSessionLoading = _suppressNextSessionLoading;
|
||||
_suppressNextSessionLoading = false;
|
||||
|
||||
if (targetId && targetId !== currentSessionId) {
|
||||
await selectSession(targetId, { keepSidebar: true });
|
||||
const showLoading = !suppressSessionLoading && !(_isFirstLoad && !hashId);
|
||||
await selectSession(targetId, { keepSidebar: true, showLoading });
|
||||
} else if (targetId && targetId === currentSessionId) {
|
||||
// Same session — just refresh the header name in case it was auto-generated
|
||||
const s = sessions.find(x => x.id === targetId);
|
||||
@@ -1549,7 +1789,7 @@ export async function loadSessions() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectSession(id, { keepSidebar = false } = {}) {
|
||||
export async function selectSession(id, { keepSidebar = false, showLoading = true } = {}) {
|
||||
// Exit compare mode cleanly if active
|
||||
if (window.compareModule && window.compareModule.isActive()) {
|
||||
window.compareModule.deactivate(true);
|
||||
@@ -1558,6 +1798,7 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
|
||||
try {
|
||||
const navToken = ++_sessionNavToken;
|
||||
const prevSessionId = currentSessionId;
|
||||
_clearHistoryPager();
|
||||
// Re-archive peeked session when navigating away
|
||||
_checkPeekCleanup(id);
|
||||
// Clear any leftover document text selection so it doesn't bleed into the new chat
|
||||
@@ -1625,7 +1866,15 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
|
||||
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
|
||||
}
|
||||
|
||||
// On mobile, keep sidebar open — user dismisses it by tapping chat area or swiping
|
||||
// On mobile manual chat switches, move the drawer away before showing the
|
||||
// loader so the status sits over the chat pane instead of being hidden by
|
||||
// the sidebar. Startup auto-restore passes keepSidebar + showLoading=false.
|
||||
if (showLoading && !keepSidebar && window.innerWidth <= 768) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
if (sidebar) sidebar.classList.add('hidden');
|
||||
if (backdrop) backdrop.classList.remove('visible');
|
||||
}
|
||||
|
||||
// Highlight active session in sidebar
|
||||
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
|
||||
@@ -1649,20 +1898,38 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
|
||||
// declaration had been removed while leaving the references in
|
||||
// place, producing a ReferenceError every selectSession.)
|
||||
const isOC = meta && (meta.is_openclaw || id === 'openclaw');
|
||||
let msgHistory = [], modelName = null;
|
||||
let msgHistory = [], modelName = null, pageInfo = null;
|
||||
let paintedLoading = false;
|
||||
let loadingTimer = null;
|
||||
let loadingPaintReady = Promise.resolve();
|
||||
if (!isOC) {
|
||||
if (chatHistory && prevSessionId !== id) {
|
||||
_paintSessionLoading(chatHistory, 'Loading chat');
|
||||
paintedLoading = true;
|
||||
await _nextPaint();
|
||||
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
|
||||
if (showLoading && chatHistory && prevSessionId !== id) {
|
||||
const loadingDelayMs = window.innerWidth <= 768 ? 900 : 500;
|
||||
loadingTimer = setTimeout(() => {
|
||||
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
|
||||
_paintSessionLoading(chatHistory, 'Loading chat');
|
||||
paintedLoading = true;
|
||||
loadingPaintReady = _nextPaint();
|
||||
}, loadingDelayMs);
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/history/${id}`);
|
||||
const res = await fetch(_historyUrl(id, { limit: _historyPageLimit() }));
|
||||
const data = await res.json();
|
||||
if (loadingTimer) {
|
||||
clearTimeout(loadingTimer);
|
||||
loadingTimer = null;
|
||||
}
|
||||
if (paintedLoading) {
|
||||
await loadingPaintReady;
|
||||
}
|
||||
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
|
||||
msgHistory = data.history || [];
|
||||
modelName = data.model || null;
|
||||
pageInfo = {
|
||||
offset: data.offset,
|
||||
limit: data.limit,
|
||||
total: data.total,
|
||||
has_more_before: !!data.has_more_before,
|
||||
};
|
||||
// The model returned by /api/history is the authoritative one the
|
||||
// backend will use for this session. Write it back into the cached
|
||||
// session meta and refresh the picker so the displayed model can
|
||||
@@ -1721,26 +1988,11 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
|
||||
'OpenClaw');
|
||||
} else if (msgHistory.length) {
|
||||
for (const msg of msgHistory) {
|
||||
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
|
||||
let displayContent;
|
||||
if (typeof msg.content === 'string') {
|
||||
displayContent = _displayHistoryContent(msg.content);
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Multimodal (image/audio attachments): extract text parts, skip binary
|
||||
displayContent = _displayHistoryContent(msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim());
|
||||
} else {
|
||||
displayContent = '';
|
||||
try {
|
||||
_renderHistoryMessage(msg, modelName);
|
||||
} catch (e) {
|
||||
console.warn('Failed to render history message:', e, msg);
|
||||
}
|
||||
// Clean up doc selection context for display
|
||||
if (msg.role === 'user') {
|
||||
// Hide "Continue where you left off" bubbles
|
||||
if (displayContent.trim() === 'Continue where you left off' || displayContent.trim().startsWith('Your message was cut off.') || displayContent.trim().startsWith('Your previous response was interrupted.') || displayContent.includes('[Instruction: Rewrite') || displayContent.includes('[Instruction: Explain')) continue;
|
||||
const docEditMatch = displayContent.match(/^In the document, edit this specific text \((lines? [\d-]+)\):\n```\n([\s\S]*?)\n```\n\nInstruction: ([\s\S]*)$/);
|
||||
if (docEditMatch) {
|
||||
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
|
||||
}
|
||||
}
|
||||
window.chatModule.addMessage(msg.role, markdownModule.renderContent(displayContent), modelName, meta);
|
||||
}
|
||||
} else {
|
||||
if (window.chatModule && window.chatModule.showWelcomeScreen) window.chatModule.showWelcomeScreen();
|
||||
@@ -1748,6 +2000,9 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
|
||||
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
|
||||
}
|
||||
uiModule.scrollHistoryInstant();
|
||||
if (!isOC && msgHistory.length) {
|
||||
_installHistoryPager(id, pageInfo, modelName);
|
||||
}
|
||||
|
||||
// Fade in and re-enable message animations
|
||||
if (chatHistory) {
|
||||
@@ -1817,6 +2072,16 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in selectSession:', error);
|
||||
const chatHistory = uiModule.el('chat-history');
|
||||
if (chatHistory?.querySelector('.session-loading-state')) {
|
||||
chatHistory.innerHTML = '';
|
||||
chatHistory.style.opacity = '1';
|
||||
chatHistory.classList.remove('no-animate');
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'msg msg-ai';
|
||||
msg.innerHTML = `<div class="body">Failed to load this chat. ${uiModule.esc ? uiModule.esc(error.message || '') : ''}</div>`;
|
||||
chatHistory.appendChild(msg);
|
||||
}
|
||||
uiModule.showError('Failed to load session: ' + error.message);
|
||||
} finally {
|
||||
// Ensure memories are loaded after session selection
|
||||
@@ -1854,6 +2119,7 @@ export function createDirectChat(url, modelId, endpointId) {
|
||||
// Don't hit the API — just store the model info and prepare the UI
|
||||
_pendingChat = { url, modelId, endpointId };
|
||||
_skipAutoSelect = true;
|
||||
_suppressNextSessionLoading = true;
|
||||
currentSessionId = null;
|
||||
Storage.remove('lastSessionId');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
@@ -1950,6 +2216,7 @@ export async function materializePendingSession() {
|
||||
|
||||
// Reload sidebar to show the new session — await it so the session
|
||||
// is fully registered before the caller proceeds (prevents race conditions)
|
||||
_suppressNextSessionLoading = true;
|
||||
await loadSessions().catch(() => {});
|
||||
return true;
|
||||
}
|
||||
@@ -1986,6 +2253,7 @@ export function setCurrentSessionId(id) {
|
||||
_sessionNavToken++;
|
||||
currentSessionId = id;
|
||||
if (!id) {
|
||||
_suppressNextSessionLoading = true;
|
||||
Storage.remove('lastSessionId');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
document.querySelectorAll('.list-item.active-session, .session-item.active').forEach(el => {
|
||||
|
||||
@@ -3135,6 +3135,8 @@ async function initEmailSettings() {
|
||||
if (el('set-email-smtp-user')) el('set-email-smtp-user').value = cfg.smtp_user || '';
|
||||
if (el('set-email-smtp-pass')) el('set-email-smtp-pass').value = '';
|
||||
if (el('set-email-from')) el('set-email-from').value = cfg.from_address || '';
|
||||
if (el('set-email-auto-translate')) el('set-email-auto-translate').checked = !!cfg.email_auto_translate;
|
||||
if (el('set-email-translate-language')) el('set-email-translate-language').value = cfg.email_translate_language || 'English';
|
||||
} catch (_) {}
|
||||
|
||||
// Load contacts config
|
||||
@@ -3165,6 +3167,8 @@ async function initEmailSettings() {
|
||||
smtp_port: parseInt(el('set-email-smtp-port').value) || 0,
|
||||
smtp_user: el('set-email-smtp-user').value,
|
||||
email_from: el('set-email-from').value,
|
||||
email_auto_translate: !!el('set-email-auto-translate')?.checked,
|
||||
email_translate_language: (el('set-email-translate-language')?.value || 'English').trim() || 'English',
|
||||
};
|
||||
const imapPass = el('set-email-imap-pass').value;
|
||||
const smtpPass = el('set-email-smtp-pass').value;
|
||||
@@ -3178,7 +3182,10 @@ async function initEmailSettings() {
|
||||
});
|
||||
const result = await res.json();
|
||||
if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
|
||||
const translateMsg = el('set-email-translate-msg');
|
||||
if (translateMsg) translateMsg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
setTimeout(() => { const translateMsg = el('set-email-translate-msg'); if (translateMsg) translateMsg.textContent = ''; }, 3000);
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = 'Failed';
|
||||
}
|
||||
|
||||
@@ -991,7 +991,7 @@ async function _cmdSessionNew(args, ctx) {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
await sessionModule.loadSessions();
|
||||
await sessionModule.selectSession(data.id);
|
||||
await sessionModule.selectSession(data.id, { showLoading: false });
|
||||
_hideWelcomeScreen();
|
||||
const shortModel = (model || '').split('/').pop();
|
||||
await typewriterReply(`New session — ${shortModel || 'ready'}.`);
|
||||
|
||||
+131
-35
@@ -212,6 +212,65 @@ async function _saveUrgentEmailSettings(prompt) {
|
||||
});
|
||||
}
|
||||
|
||||
const _EMAIL_ACCOUNT_ACTIONS = new Set([
|
||||
'summarize_emails',
|
||||
'draft_email_replies',
|
||||
'extract_email_events',
|
||||
'check_email_urgency',
|
||||
]);
|
||||
|
||||
let _emailAccounts = null;
|
||||
async function _fetchEmailAccountsForTasks() {
|
||||
if (_emailAccounts) return _emailAccounts;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
_emailAccounts = Array.isArray(data.accounts) ? data.accounts : [];
|
||||
} catch (e) {
|
||||
_emailAccounts = [];
|
||||
}
|
||||
return _emailAccounts;
|
||||
}
|
||||
|
||||
function _taskPromptConfig(prompt) {
|
||||
const raw = (prompt || '').trim();
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch (_) {
|
||||
const cfg = {};
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const idx = line.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const val = line.slice(idx + 1).trim();
|
||||
if (key) cfg[key] = val;
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
|
||||
async function _renderEmailActionOptions(action, existing, extra) {
|
||||
if (!_EMAIL_ACCOUNT_ACTIONS.has(action)) return;
|
||||
const accounts = (await _fetchEmailAccountsForTasks()).filter(a => a && a.enabled !== false);
|
||||
const cfg = _taskPromptConfig(existing?.prompt || '');
|
||||
const current = String(cfg.account_id || cfg.email_account_id || '');
|
||||
const options = [
|
||||
`<option value="" ${current ? '' : 'selected'}>All accounts</option>`,
|
||||
...accounts.map(a => {
|
||||
const id = String(a.id || '');
|
||||
const label = a.name || a.from_address || a.imap_user || id.slice(0, 8);
|
||||
const suffix = a.is_default ? ' (default)' : '';
|
||||
return `<option value="${_escHtml(id)}" ${id === current ? 'selected' : ''}>${_escHtml(label + suffix)}</option>`;
|
||||
}),
|
||||
].join('');
|
||||
extra.insertAdjacentHTML('afterbegin', `
|
||||
<label class="task-form-label">Email account</label>
|
||||
<select id="task-form-email-account" class="task-form-input">${options}</select>
|
||||
`);
|
||||
}
|
||||
|
||||
let _triggerEvents = null;
|
||||
async function _fetchEvents() {
|
||||
if (_triggerEvents) return _triggerEvents;
|
||||
@@ -688,9 +747,9 @@ function _renderList() {
|
||||
const titleRow = document.createElement('div');
|
||||
titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
|
||||
const statusBadge = task.status === 'paused'
|
||||
? `<span class="task-status-badge task-state-badge task-paused-badge" data-task-status-action="resume" title="Paused - click to resume" style="position:relative;top:4px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="7 4 19 12 7 20 7 4"/></svg><span class="task-state-label">paused</span></span>`
|
||||
? `<span class="task-status-badge task-state-badge task-paused-badge" data-task-status-action="resume" title="Paused - click to resume" style="position:relative;top:4px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg><span class="task-state-label">paused</span></span>`
|
||||
: task.status === 'active'
|
||||
? `<span class="task-status-badge task-state-badge task-active-badge" data-task-status-action="pause" title="Active - click to pause" style="position:relative;top:4px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg><span class="task-state-label">active</span></span>`
|
||||
? `<span class="task-status-badge task-state-badge task-active-badge" data-task-status-action="pause" title="Active - click to pause" style="position:relative;top:4px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="7 4 19 12 7 20 7 4"/></svg><span class="task-state-label">active</span></span>`
|
||||
: '';
|
||||
const builtinBadge = task.is_builtin
|
||||
? `<span class="task-builtin-badge${task.is_modified ? ' modified' : ''}" title="${task.is_modified ? 'Built-in task — edited from its default' : 'Built-in task'}">built-in${task.is_modified ? ' · edited' : ''}</span>`
|
||||
@@ -709,8 +768,8 @@ function _renderList() {
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const items = [];
|
||||
// Run now stays in the kebab too (alongside the new Run button on the
|
||||
// card) for users coming from muscle-memory / mobile long-press.
|
||||
// Run now stays in the kebab too for users coming from muscle-memory /
|
||||
// mobile long-press. The expanded card also shows it next to Edit.
|
||||
if (task.status !== 'completed') items.push({ label: 'Run now', icon: '<polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>', action: () => _doRunNow(task.id) });
|
||||
items.push({ label: 'Edit', icon: '<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"/>', action: () => _showForm(task) });
|
||||
if (task.status === 'active') items.push({ label: 'Pause', icon: '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>', action: () => _doPause(task.id) });
|
||||
@@ -726,17 +785,6 @@ function _renderList() {
|
||||
_showTaskDropdown(menuBtn, items);
|
||||
});
|
||||
actionsWrap.appendChild(menuBtn);
|
||||
// Run now — promoted out of the kebab onto the card itself for one-click
|
||||
// manual triggering. Hidden for completed tasks (same gate as before).
|
||||
if (task.status !== 'completed') {
|
||||
const runBtn = document.createElement('button');
|
||||
runBtn.className = 'task-status-badge task-run-now-badge task-card-run-btn';
|
||||
runBtn.title = 'Run now';
|
||||
runBtn.style.cssText = 'position:relative;top:1px;margin-right:4px;';
|
||||
runBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Run</span>';
|
||||
runBtn.addEventListener('click', (e) => { e.stopPropagation(); _doRunNow(task.id); });
|
||||
actionsWrap.insertBefore(runBtn, menuBtn);
|
||||
}
|
||||
titleRow.appendChild(actionsWrap);
|
||||
|
||||
// Content area
|
||||
@@ -767,7 +815,29 @@ function _renderList() {
|
||||
// Expandable detail (revealed on click) — like the library doc/chat cards:
|
||||
// extra meta + last-run result + description.
|
||||
const detail = document.createElement('div');
|
||||
detail.style.cssText = 'display:none;margin-top:7px;padding:8px 0 2px;border-top:1px solid var(--border);';
|
||||
detail.style.cssText = 'display:none;margin-top:7px;padding:8px 0 2px;border-top:1px solid var(--border);position:relative;';
|
||||
const detailActions = document.createElement('div');
|
||||
detailActions.style.cssText = 'display:flex;justify-content:flex-end;gap:6px;margin-top:7px;';
|
||||
if (task.status !== 'completed') {
|
||||
const runBtn = document.createElement('button');
|
||||
runBtn.className = 'memory-toolbar-btn task-detail-run-btn';
|
||||
runBtn.title = 'Run now';
|
||||
runBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Run';
|
||||
runBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_doRunNow(task.id);
|
||||
});
|
||||
detailActions.appendChild(runBtn);
|
||||
}
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'memory-toolbar-btn task-detail-edit-btn';
|
||||
editBtn.title = 'Edit task';
|
||||
editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><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>Edit';
|
||||
editBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_showForm(task);
|
||||
});
|
||||
detailActions.appendChild(editBtn);
|
||||
const extra = [];
|
||||
if (task.last_run) extra.push('Last: ' + _relativeTime(task.last_run));
|
||||
if (task.output_target && task.output_target !== 'session') extra.push('→ ' + task.output_target.replace(/^mcp__/, '').replace(/__/g, ' › '));
|
||||
@@ -803,6 +873,7 @@ function _renderList() {
|
||||
}
|
||||
detail.appendChild(desc);
|
||||
}
|
||||
detail.appendChild(detailActions);
|
||||
content.appendChild(detail);
|
||||
|
||||
// Select-mode checkbox (mirrors the library's .memory-select-cb).
|
||||
@@ -898,10 +969,19 @@ function _attachTaskLongPress(card, menuBtn) {
|
||||
}
|
||||
|
||||
function _showTaskDropdown(anchor, items) {
|
||||
// Remove any existing dropdown
|
||||
document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
|
||||
const existing = document.querySelector('.task-dropdown');
|
||||
if (existing && existing._anchor === anchor) {
|
||||
if (typeof existing._dismiss === 'function') existing._dismiss();
|
||||
else existing.remove();
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.task-dropdown').forEach(d => {
|
||||
if (typeof d._dismiss === 'function') d._dismiss();
|
||||
else d.remove();
|
||||
});
|
||||
const dd = document.createElement('div');
|
||||
dd.className = 'task-dropdown';
|
||||
dd._anchor = anchor;
|
||||
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
|
||||
items.forEach(item => {
|
||||
const btn = document.createElement('button');
|
||||
@@ -933,6 +1013,10 @@ function _showTaskDropdown(anchor, items) {
|
||||
if (performance.now() - openedAt < 250) return;
|
||||
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
|
||||
};
|
||||
dd._dismiss = () => {
|
||||
dd.remove();
|
||||
document.removeEventListener('click', close);
|
||||
};
|
||||
// requestAnimationFrame so the listener is registered AFTER the current
|
||||
// pointer/click event cycle has finished bubbling.
|
||||
requestAnimationFrame(() => document.addEventListener('click', close));
|
||||
@@ -1061,10 +1145,13 @@ function _showForm(existing, initTaskType, initTriggerType) {
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
|
||||
<label class="task-form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer;">
|
||||
<input type="checkbox" id="task-form-notif" ${existing && existing.notifications_enabled === false ? '' : 'checked'} style="margin:0;cursor:pointer;">
|
||||
<span>Notifications</span>
|
||||
<span style="opacity:0.55;font-weight:normal;font-size:10px;">— uncheck to silence completion notifications for this task (helpful for chatty cron jobs)</span>
|
||||
<label class="task-form-notif-toggle">
|
||||
<input type="checkbox" id="task-form-notif" ${existing && existing.notifications_enabled === false ? '' : 'checked'}>
|
||||
<span class="task-form-notif-switch" aria-hidden="true"></span>
|
||||
<span class="task-form-notif-copy">
|
||||
<span>Notifications</span>
|
||||
<span>Silence completion alerts for chatty cron jobs.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="task-form-actions">
|
||||
@@ -1114,23 +1201,28 @@ function _showForm(existing, initTaskType, initTriggerType) {
|
||||
const sel = document.getElementById('task-form-action');
|
||||
const extra = document.getElementById('task-form-action-extra');
|
||||
if (!sel || !extra) return;
|
||||
if (sel.value !== 'check_email_urgency') {
|
||||
const action = sel.value;
|
||||
if (!_EMAIL_ACCOUNT_ACTIONS.has(action)) {
|
||||
extra.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
extra.innerHTML = `
|
||||
<label class="task-form-label">Email triage rules</label>
|
||||
<textarea id="task-form-urgent-email-prompt" class="task-form-input task-form-textarea" rows="4" placeholder="What should count as urgent? e.g. deadlines, blockers, people waiting outside."></textarea>
|
||||
<div class="memory-desc" style="font-size:11px;margin-top:4px;">Pause/resume and schedule are controlled by this task. It tags urgent, reply-soon, newsletter, marketing, and spam. Urgent/reply-soon emails use your reminder settings.</div>
|
||||
`;
|
||||
const settings = await _fetchUrgentEmailSettings();
|
||||
const promptEl = document.getElementById('task-form-urgent-email-prompt');
|
||||
if (promptEl && !promptEl.dataset.loaded) {
|
||||
promptEl.value = settings.urgent_email_prompt || '';
|
||||
promptEl.dataset.loaded = '1';
|
||||
extra.innerHTML = '';
|
||||
await _renderEmailActionOptions(action, existing, extra);
|
||||
if (action === 'check_email_urgency') {
|
||||
extra.insertAdjacentHTML('beforeend', `
|
||||
<label class="task-form-label">Email triage rules</label>
|
||||
<textarea id="task-form-urgent-email-prompt" class="task-form-input task-form-textarea" rows="4" placeholder="What should count as urgent? e.g. deadlines, blockers, people waiting outside."></textarea>
|
||||
<div class="memory-desc" style="font-size:11px;margin-top:4px;">Pause/resume and schedule are controlled by this task. It tags work, personal, urgent, action-needed, finance, legal, travel, newsletter, marketing, spam, and related mail categories. Urgent/reply-soon emails use your reminder settings.</div>
|
||||
`);
|
||||
const settings = await _fetchUrgentEmailSettings();
|
||||
const promptEl = document.getElementById('task-form-urgent-email-prompt');
|
||||
if (promptEl && !promptEl.dataset.loaded) {
|
||||
promptEl.value = settings.urgent_email_prompt || '';
|
||||
promptEl.dataset.loaded = '1';
|
||||
}
|
||||
const notifEl = document.getElementById('task-form-notif');
|
||||
if (notifEl && !existing?.id) notifEl.checked = false;
|
||||
}
|
||||
const notifEl = document.getElementById('task-form-notif');
|
||||
if (notifEl && !existing?.id) notifEl.checked = false;
|
||||
};
|
||||
_fetchActions().then(actions => {
|
||||
const sel = document.getElementById('task-form-action');
|
||||
@@ -1469,6 +1561,10 @@ function _showForm(existing, initTaskType, initTriggerType) {
|
||||
return;
|
||||
}
|
||||
payload.action = action;
|
||||
if (_EMAIL_ACCOUNT_ACTIONS.has(action)) {
|
||||
const accountId = document.getElementById('task-form-email-account')?.value || '';
|
||||
payload.prompt = accountId ? JSON.stringify({ account_id: accountId }) : '';
|
||||
}
|
||||
if (action === 'check_email_urgency') {
|
||||
const urgentPrompt = document.getElementById('task-form-urgent-email-prompt')?.value || '';
|
||||
try {
|
||||
|
||||
@@ -373,25 +373,6 @@ export function showToast(msg, durationOrOpts) {
|
||||
|
||||
toastEl.appendChild(stack);
|
||||
|
||||
// Small × to dismiss the toast without taking the action. Useful when
|
||||
// the user already acted (or just doesn't want the banner sitting there).
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss');
|
||||
closeBtn.title = 'Dismiss';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.style.cssText = 'margin-left:8px;padding:0;width:20px;height:20px;line-height:1;border:none;background:none;color:var(--fg);opacity:0.55;cursor:pointer;font-size:18px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;pointer-events:auto;';
|
||||
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.opacity = '1'; });
|
||||
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.opacity = '0.55'; });
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
clearTimeout(toastEl._hideTimer);
|
||||
toastEl.classList.add('exiting');
|
||||
toastEl.classList.remove('show');
|
||||
});
|
||||
toastEl.appendChild(closeBtn);
|
||||
|
||||
toastEl.style.pointerEvents = 'auto';
|
||||
} else {
|
||||
// No action — restore the default non-blocking behavior.
|
||||
|
||||
+1198
-172
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user