Polish mobile UI and editor workflows

This commit is contained in:
pewdiepie-archdaemon
2026-06-27 13:05:44 +00:00
parent 87e46e576a
commit 45ee5a71f4
48 changed files with 6455 additions and 1177 deletions
+49 -7
View File
@@ -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 (_) {}
+28
View File
@@ -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
View File
@@ -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;
+15 -1
View File
@@ -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
+11 -5
View File
@@ -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)');
}
+7 -5
View File
@@ -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
View File
@@ -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 Odysseuss 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 Odysseuss 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
View File
@@ -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 tempreal. */
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
View File
@@ -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 &rsaquo; 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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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,
+19
View File
@@ -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
View File
@@ -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', () => {
+31 -5
View File
@@ -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) {
+5 -4
View File
@@ -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
View File
@@ -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 => {
+7
View File
@@ -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';
}
+1 -1
View File
@@ -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
View File
@@ -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 {
-19
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff