Polish email and cookbook flows
This commit is contained in:
@@ -213,9 +213,33 @@ export function _renderGpuToggles(system) {
|
||||
// reload paints instantly, then we refresh in the background and swap.
|
||||
const _SCAN_CACHE_KEY = 'hwfit_scan_cache_v1';
|
||||
const _MANUAL_HW_KEY = 'hwfit_manual_hardware_v1';
|
||||
const _CTX_KEY = 'hwfit_target_context_v1';
|
||||
const _CTX_PRESETS = [8192, 16384, 32768, 50000, 131072, 0]; // 0 = model max
|
||||
const _SCAN_CACHE_MAX = 12; // keep the newest N signatures
|
||||
const _SCAN_CACHE_TTL = 6 * 3600 * 1000; // 6 h — hardware rarely changes
|
||||
|
||||
function _ctxLabel(value) {
|
||||
const n = Number(value) || 0;
|
||||
if (!n) return 'Max';
|
||||
return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n);
|
||||
}
|
||||
|
||||
function _ctxValue() {
|
||||
const slider = document.getElementById('hwfit-context');
|
||||
const idx = Math.max(0, Math.min(_CTX_PRESETS.length - 1, Number(slider?.value ?? 3) || 0));
|
||||
return _CTX_PRESETS[idx] || 0;
|
||||
}
|
||||
|
||||
function _syncCtxControl() {
|
||||
const slider = document.getElementById('hwfit-context');
|
||||
const label = document.getElementById('hwfit-context-label');
|
||||
if (!slider) return;
|
||||
const saved = localStorage.getItem(_CTX_KEY);
|
||||
const savedIdx = saved == null ? 3 : _CTX_PRESETS.indexOf(Number(saved));
|
||||
slider.value = String(savedIdx >= 0 ? savedIdx : 3);
|
||||
if (label) label.textContent = _ctxLabel(_ctxValue());
|
||||
}
|
||||
|
||||
function _manualHwState() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(_MANUAL_HW_KEY) || '{}');
|
||||
@@ -316,6 +340,7 @@ function _scanSig() {
|
||||
o: sortEl?.value || 'score',
|
||||
r: sortEl?.dataset.reverse === '1' ? 1 : 0,
|
||||
q: document.getElementById('hwfit-quant')?.value || '',
|
||||
c: _ctxValue(),
|
||||
g: (tc && typeof tc._activeCount === 'number') ? String(tc._activeCount) : '',
|
||||
gg: (tc && tc._activeGroup) ? String(tc._activeGroup) : '',
|
||||
m: _manualHwParams(),
|
||||
@@ -440,6 +465,7 @@ export async function _hwfitFetch(fresh = false) {
|
||||
try {
|
||||
const sortBy = document.getElementById('hwfit-sort')?.value || 'score';
|
||||
const quantPref = document.getElementById('hwfit-quant')?.value || '';
|
||||
const targetCtx = _ctxValue();
|
||||
// Get active GPU count from toggles
|
||||
const toggleContainer = document.getElementById('hwfit-gpu-toggles');
|
||||
let gpuCountOverride = '';
|
||||
@@ -475,6 +501,7 @@ export async function _hwfitFetch(fresh = false) {
|
||||
if (!isImageMode) {
|
||||
if (useCase) params.set('use_case', useCase);
|
||||
if (quantPref) params.set('quant', quantPref);
|
||||
if (targetCtx) params.set('ctx', String(targetCtx));
|
||||
}
|
||||
const endpoint = isImageMode ? `/api/hwfit/image-models?${params}` : `/api/hwfit/models?${params}`;
|
||||
const res = await fetch(endpoint);
|
||||
@@ -770,6 +797,20 @@ function _wireManualHardwareControls(el) {
|
||||
|
||||
export const _fitColors = { perfect: 'var(--green, #50fa7b)', good: 'var(--yellow, #f1fa8c)', marginal: 'var(--orange, #ffb86c)', too_tight: 'var(--red, #ff5555)' };
|
||||
|
||||
function _requiresAcceleratorBackend(model) {
|
||||
const q = String(model?.quant || model?.quantization || '').toUpperCase();
|
||||
const text = `${model?.name || ''} ${model?.repo_id || ''} ${model?.path || ''}`.toLowerCase();
|
||||
return /^AWQ|^GPTQ|^NVFP4/.test(q) || q === 'FP8' || /\b(awq|gptq|fp8|nvfp4)\b/i.test(text);
|
||||
}
|
||||
|
||||
function _modeLabel(model) {
|
||||
if (model?.is_image_gen) return 'image';
|
||||
if (_requiresAcceleratorBackend(model)) return 'vLLM/SGLang';
|
||||
const detected = _detectBackend(model);
|
||||
if (detected?.label) return detected.label;
|
||||
return String(model?.run_mode || '').replace('_', '+');
|
||||
}
|
||||
|
||||
export const _hwfitColumns = [
|
||||
{ key: 'fit', label: 'Fit', cls: 'hwfit-fit' },
|
||||
{ key: null, label: 'Model', cls: 'hwfit-name' },
|
||||
@@ -827,9 +868,7 @@ export function _hwfitRenderList(el, models) {
|
||||
const pcount = m.parameter_count || '?';
|
||||
const ctx = m.context ? (m.context >= 1024 ? (m.context / 1024).toFixed(0) + 'k' : m.context) : '?';
|
||||
const fitLabel = (m.fit_level || '').replace('_', ' ');
|
||||
const modeLabel = m.run_mode === 'cpu_offload'
|
||||
? 'cpu+offload'
|
||||
: (m.run_mode || '').replace(/_/g, ' ');
|
||||
const modeLabel = _modeLabel(m);
|
||||
const vramLabel = m.required_gb ? m.required_gb.toFixed(1) + 'G' : '?';
|
||||
const moeBadge = m.is_moe ? '<span class="hwfit-badge hwfit-moe">MoE</span>' : '';
|
||||
const imgBadge = m.is_image_gen ? '<span class="hwfit-badge" style="background:color-mix(in srgb, var(--red) 20%, transparent);color:var(--red);font-size:8px;padding:1px 4px;border-radius:3px;margin-left:4px;">IMG</span>' : '';
|
||||
@@ -843,7 +882,7 @@ export function _hwfitRenderList(el, models) {
|
||||
html += `<span class="hwfit-col hwfit-c-ctx">${m.is_image_gen ? '\u2014' : ctx}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-speed">${m.is_image_gen ? '\u2014' : tps + ' t/s'}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-score">${score}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-mode">${m.is_image_gen ? 'image' : esc(modeLabel)}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-mode" title="${_requiresAcceleratorBackend(m) ? 'Requires vLLM or SGLang with a visible CUDA/ROCm accelerator. llama.cpp and Ollama need GGUF files.' : ''}">${esc(modeLabel)}</span>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
@@ -943,6 +982,8 @@ export function _expandModelRow(row, modelData) {
|
||||
html += `</div>`;
|
||||
if (modelData.is_image_gen) {
|
||||
html += `<div style="font-size:10px;opacity:0.5;margin-top:4px;">${esc((modelData.capabilities || []).join(' \u00B7 ') || '')}${modelData.description ? ' \u2014 ' + esc(modelData.description) : ''}</div>`;
|
||||
} else if (_requiresAcceleratorBackend(modelData)) {
|
||||
html += `<div class="hwfit-panel-note">This is a safetensors GPU-serving format. Use vLLM/SGLang with a visible CUDA/ROCm accelerator, or pick a GGUF download for llama.cpp/Ollama.</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
@@ -1139,8 +1180,11 @@ export function _hwfitInit() {
|
||||
const uc = document.getElementById('hwfit-usecase');
|
||||
const sort = document.getElementById('hwfit-sort');
|
||||
const qpref = document.getElementById('hwfit-quant');
|
||||
const ctx = document.getElementById('hwfit-context');
|
||||
const ctxLabel = document.getElementById('hwfit-context-label');
|
||||
const search = document.getElementById('hwfit-search');
|
||||
const remote = document.getElementById('hwfit-host');
|
||||
_syncCtxControl();
|
||||
if (uc) uc.addEventListener('change', () => _hwfitFetch());
|
||||
if (sort) sort.addEventListener('change', () => _hwfitFetch());
|
||||
if (qpref) qpref.addEventListener('change', () => _hwfitFetch());
|
||||
@@ -1155,6 +1199,28 @@ export function _hwfitInit() {
|
||||
_hwfitFetch();
|
||||
}
|
||||
});
|
||||
if (ctx && !ctx.dataset.bound) {
|
||||
ctx.dataset.bound = '1';
|
||||
ctx.addEventListener('input', () => {
|
||||
if (ctxLabel) ctxLabel.textContent = _ctxLabel(_ctxValue());
|
||||
});
|
||||
ctx.addEventListener('change', () => {
|
||||
const targetCtx = _ctxValue();
|
||||
try { localStorage.setItem(_CTX_KEY, String(targetCtx)); } catch {}
|
||||
const sortSel = document.getElementById('hwfit-sort');
|
||||
if (sortSel) {
|
||||
if (targetCtx) {
|
||||
sortSel.value = 'fit';
|
||||
sortSel.dataset.reverse = '1';
|
||||
} else {
|
||||
sortSel.value = 'score';
|
||||
sortSel.dataset.reverse = '';
|
||||
}
|
||||
}
|
||||
_hwfitCache = null;
|
||||
_hwfitFetch();
|
||||
});
|
||||
}
|
||||
// Rescan — force a fresh hardware probe (bypasses the per-host cache).
|
||||
const rescan = document.getElementById('hwfit-rescan');
|
||||
if (rescan && !rescan.dataset.bound) {
|
||||
|
||||
@@ -1528,6 +1528,10 @@ function _renderRecipes() {
|
||||
html += '<option value="vllm">vLLM</option>';
|
||||
html += '<option value="sglang">SGLang</option>';
|
||||
html += '</select>';
|
||||
html += '<span class="hwfit-help-chip" title="Higher numbers usually mean better quality, but they need more memory. Lower numbers fit on more hardware.">?</span>';
|
||||
html += '<label class="hwfit-ctx-control" title="Context length for fit estimates. Lower it to find more models that could fit your hardware.">';
|
||||
html += '<span>Ctx</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
|
||||
html += '<output id="hwfit-context-label">50k</output></label>';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
|
||||
html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">';
|
||||
|
||||
@@ -495,6 +495,10 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
|
||||
const payload = { repo_id: repo };
|
||||
if (include) payload.include = include;
|
||||
// Large downloads are where hf_transfer most often dies near the end. Use the
|
||||
// plain HuggingFace downloader up front for big model files; it is slower, but
|
||||
// resumes cached partials more reliably.
|
||||
if ((model.required_gb || 0) >= 10 || backend === 'llamacpp') payload.disable_hf_transfer = true;
|
||||
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
|
||||
if (host) { payload.remote_host = host; const _sp = _getPort(host); if (_sp) payload.ssh_port = _sp; }
|
||||
if (platform) payload.platform = platform;
|
||||
@@ -519,6 +523,18 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
const targetHost = host || 'local';
|
||||
|
||||
const tasks = _loadTasks();
|
||||
const sameDownload = (t) => {
|
||||
if (!t || t.type !== 'download') return false;
|
||||
const tRepo = t?.payload?.repo_id || t?.repo_id || t?.repo || t?.name || '';
|
||||
const tHost = t?.remoteHost || t?.payload?.remote_host || 'local';
|
||||
return String(tRepo) === String(payload.repo_id) && String(tHost || 'local') === String(targetHost);
|
||||
};
|
||||
const duplicate = tasks.find(t => sameDownload(t) && (t.status === 'running' || t.status === 'queued'));
|
||||
if (duplicate) {
|
||||
_renderRunningTab();
|
||||
uiModule.showToast(`${shortName} is already ${duplicate.status === 'queued' ? 'queued' : 'downloading'}`);
|
||||
return;
|
||||
}
|
||||
const activeOnHost = tasks.find(t => t.type === 'download' && (t.status === 'running' || t.status === 'queued') && (t.remoteHost || 'local') === targetHost);
|
||||
|
||||
if (activeOnHost) {
|
||||
|
||||
@@ -37,7 +37,6 @@ function _taskBadge(task) {
|
||||
function _canClearTask(task) {
|
||||
if (!task || task.status === 'running') return false;
|
||||
if (task.type === 'serve' && (task.status === 'ready' || task._serveReady)) return false;
|
||||
if (task.type === 'download' && task.status === 'done' && !task.payload?._dep) return false;
|
||||
return ['done', 'stopped', 'error', 'crashed', 'failed'].includes(task.status);
|
||||
}
|
||||
|
||||
@@ -379,7 +378,7 @@ function _refreshModelsAfterEndpointChange() {
|
||||
// ── Download queue — runs one at a time per server ──
|
||||
|
||||
function _processQueue() {
|
||||
const tasks = _loadTasks();
|
||||
const tasks = _loadPrunedTasks();
|
||||
const running = tasks.filter(t => t.type === 'download' && t.status === 'running');
|
||||
const queued = tasks.filter(t => t.type === 'download' && t.status === 'queued');
|
||||
if (!queued.length) return;
|
||||
@@ -433,14 +432,24 @@ async function _startQueuedDownload(task) {
|
||||
return;
|
||||
}
|
||||
const oldId = task.sessionId;
|
||||
const tasks = _loadTasks();
|
||||
const t = tasks.find(t => t.sessionId === oldId);
|
||||
if (t) {
|
||||
t.sessionId = data.session_id;
|
||||
t.id = data.session_id;
|
||||
t.status = 'running';
|
||||
_saveTasks(tasks);
|
||||
}
|
||||
const launchedTask = { ...task, sessionId: data.session_id, id: data.session_id, status: 'running' };
|
||||
const key = _downloadDedupeKey(launchedTask);
|
||||
let found = false;
|
||||
const tasks = _loadTasks().filter(t => {
|
||||
if (t.sessionId === oldId) {
|
||||
found = true;
|
||||
t.sessionId = data.session_id;
|
||||
t.id = data.session_id;
|
||||
t.status = 'running';
|
||||
t._startLaunched = true;
|
||||
return true;
|
||||
}
|
||||
if (t.sessionId === data.session_id) return false;
|
||||
return !(key && t.type === 'download' && t.status === 'queued' && _downloadDedupeKey(t) === key);
|
||||
});
|
||||
if (!found) tasks.push(_stripTaskSecrets(launchedTask));
|
||||
_saveTasks(tasks);
|
||||
_renderRunningTab();
|
||||
_startBackgroundMonitor();
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
_renderRunningTab();
|
||||
@@ -473,6 +482,53 @@ export function _loadTasks() {
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function _downloadRepoKey(task) {
|
||||
return String(task?.payload?.repo_id || task?.repo_id || task?.repo || task?.name || '').trim();
|
||||
}
|
||||
|
||||
function _downloadHostKey(task) {
|
||||
return String(task?.remoteHost || task?.payload?.remote_host || 'local').trim() || 'local';
|
||||
}
|
||||
|
||||
function _downloadDedupeKey(task) {
|
||||
if (!task || task.type !== 'download') return '';
|
||||
const repo = _downloadRepoKey(task);
|
||||
if (!repo) return '';
|
||||
return `${_downloadHostKey(task)}\n${repo}`;
|
||||
}
|
||||
|
||||
function _pruneQueuedDownloadDuplicates(tasks) {
|
||||
if (!Array.isArray(tasks) || !tasks.length) return tasks || [];
|
||||
const launched = new Set();
|
||||
for (const task of tasks) {
|
||||
if (task?.type !== 'download' || task.status === 'queued') continue;
|
||||
const key = _downloadDedupeKey(task);
|
||||
if (key) launched.add(key);
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const seenQueued = new Set();
|
||||
const next = tasks.filter(task => {
|
||||
if (task?.type !== 'download' || task.status !== 'queued') return true;
|
||||
const key = _downloadDedupeKey(task);
|
||||
if (!key) return true;
|
||||
if (launched.has(key) || seenQueued.has(key)) {
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
seenQueued.add(key);
|
||||
return true;
|
||||
});
|
||||
return changed ? next : tasks;
|
||||
}
|
||||
|
||||
function _loadPrunedTasks() {
|
||||
const tasks = _loadTasks();
|
||||
const pruned = _pruneQueuedDownloadDuplicates(tasks);
|
||||
if (pruned !== tasks) _saveTasks(pruned);
|
||||
return pruned;
|
||||
}
|
||||
|
||||
// Tombstones for removed tasks. Without these, removing a task only deletes it
|
||||
// locally — but the server still has it (its own POST guard even re-preserves
|
||||
// recently-added ones), so the next sync/poll merges it right back ("I removed
|
||||
@@ -535,6 +591,13 @@ export function _addTask(sessionId, name, type, payload) {
|
||||
const _repoId = payload.repo_id;
|
||||
tasks = tasks.filter(t => !(t.type === 'download' && t.status === 'done' && t.payload && t.payload.repo_id === _repoId));
|
||||
}
|
||||
if (type === 'download' && payload && payload.repo_id) {
|
||||
const key = _downloadDedupeKey({ type: 'download', payload, remoteHost });
|
||||
tasks = tasks.filter(t => {
|
||||
if (t.sessionId === sessionId) return false;
|
||||
return !(key && t.type === 'download' && t.status === 'queued' && _downloadDedupeKey(t) === key);
|
||||
});
|
||||
}
|
||||
const task = _stripTaskSecrets({ id: sessionId, sessionId, name, type, status: 'running', output: '', ts: Date.now(), payload: payload || null, remoteHost, sshPort, platform });
|
||||
tasks.push(task);
|
||||
_saveTasks(tasks);
|
||||
@@ -651,6 +714,53 @@ function _tmuxGracefulKill(task) {
|
||||
return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`;
|
||||
}
|
||||
|
||||
function _shQuote(value) {
|
||||
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
function _taskLooksOllama(task, outputText = '') {
|
||||
const haystack = `${task?.payload?.backend || ''} ${task?.payload?._cmd || ''} ${task?.payload?._fields?.backend || ''} ${outputText || ''}`;
|
||||
return /\bollama\b/i.test(haystack) || /Ollama API ready on port\s+\d+/i.test(haystack);
|
||||
}
|
||||
|
||||
function _ollamaBaseUrlForTask(task, outputText = '') {
|
||||
const out = String(outputText || '');
|
||||
const ready = out.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
|
||||
if (ready) return ready[1].replace(/\/+$/, '');
|
||||
const cmd = String(task?.payload?._cmd || '');
|
||||
const host = cmd.match(/OLLAMA_HOST=([^\s]+)/)?.[1] || '';
|
||||
const port = host.match(/:(\d+)$/)?.[1] || '11434';
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
function _ollamaModelForTask(task) {
|
||||
return String(task?.payload?.model || task?.payload?.repo_id || task?.name || '').trim();
|
||||
}
|
||||
|
||||
function _ollamaUnloadCommand(task, outputText = '') {
|
||||
if (!_taskLooksOllama(task, outputText)) return '';
|
||||
const model = _ollamaModelForTask(task);
|
||||
if (!model) return '';
|
||||
const base = _ollamaBaseUrlForTask(task, outputText);
|
||||
const body = JSON.stringify({ model, prompt: '', keep_alive: 0, stream: false });
|
||||
const inner = `curl -sf -X POST ${_shQuote(base + '/api/generate')} -H 'Content-Type: application/json' -d ${_shQuote(body)} >/dev/null 2>&1 || true`;
|
||||
if (task.remoteHost) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`;
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
function _endpointUrlForTask(task, outputText = '') {
|
||||
if (_taskLooksOllama(task, outputText)) {
|
||||
return _ollamaBaseUrlForTask(task, outputText) + '/v1';
|
||||
}
|
||||
const rawHost = task.remoteHost || 'localhost';
|
||||
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
||||
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
return `http://${host}:${port}/v1`;
|
||||
}
|
||||
|
||||
// ── Wave animation ──
|
||||
|
||||
const _waveFrames = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▅', '▆▅▄', '▅▄▃', '▄▃▂', '▃▂▁'];
|
||||
@@ -909,17 +1019,23 @@ async function _retryTask(el, task) {
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
|
||||
});
|
||||
} catch {}
|
||||
_removeTask(task.sessionId);
|
||||
if (task.payload) {
|
||||
if (task.type === 'serve' && task.payload._cmd) {
|
||||
_removeTask(task.sessionId);
|
||||
_launchServeTask(task.name, task.payload.repo_id, task.payload._cmd, task.payload._fields, task.remoteHost || '');
|
||||
} else {
|
||||
_retryDownload(task.name, task.payload);
|
||||
uiModule.showToast('Retrying download — progress may look reset while HuggingFace checks cached files, then it should resume.', 7000);
|
||||
_updateTask(task.sessionId, {
|
||||
status: 'running',
|
||||
output: `${task.output || ''}\n\n[odysseus] Retrying download. Progress may briefly look like a fresh download while HuggingFace checks cached/incomplete files; cached partial files will be reused when available.`.trim(),
|
||||
_retrying: true,
|
||||
});
|
||||
_retryDownload(task.name, task.payload, task.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _retryDownload(name, payload) {
|
||||
async function _retryDownload(name, payload, replaceSessionId = '') {
|
||||
try {
|
||||
// A retry means the fast hf_transfer path already failed once — fall back to
|
||||
// the plain, reliable downloader for this and any further attempt (it resumes
|
||||
@@ -932,17 +1048,40 @@ async function _retryDownload(name, payload) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
uiModule.showToast('Download failed: HTTP ' + res.status);
|
||||
if (replaceSessionId) _updateTask(replaceSessionId, { status: 'crashed', _retrying: false });
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
uiModule.showToast('Download failed: ' + (data.error || ''));
|
||||
if (replaceSessionId) _updateTask(replaceSessionId, { status: 'crashed', _retrying: false });
|
||||
return;
|
||||
}
|
||||
_addTask(data.session_id, name, 'download', _payload);
|
||||
if (replaceSessionId) {
|
||||
const tasks = _loadTasks();
|
||||
const task = tasks.find(t => t.sessionId === replaceSessionId);
|
||||
if (task) {
|
||||
task.id = data.session_id;
|
||||
task.sessionId = data.session_id;
|
||||
task.status = 'running';
|
||||
task.output = '';
|
||||
task.ts = Date.now();
|
||||
task.payload = _payload;
|
||||
task._retrying = false;
|
||||
_saveTasks(tasks);
|
||||
_soloExpandTaskId = data.session_id;
|
||||
_renderRunningTab();
|
||||
_startBackgroundMonitor();
|
||||
} else {
|
||||
_addTask(data.session_id, name, 'download', _payload);
|
||||
}
|
||||
} else {
|
||||
_addTask(data.session_id, name, 'download', _payload);
|
||||
}
|
||||
uiModule.showToast(`Downloading ${name}...`);
|
||||
} catch (e) {
|
||||
uiModule.showToast('Download failed: ' + e.message);
|
||||
if (replaceSessionId) _updateTask(replaceSessionId, { status: 'crashed', _retrying: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1326,7 +1465,7 @@ export function _renderRunningTab() {
|
||||
// event but the matching clear only ran on modal-open, so the highlight
|
||||
// persisted indefinitely after tasks finished in the background.
|
||||
try {
|
||||
const _activeTasks = _loadTasks().filter(t => t.status === 'running' || t.status === 'queued' || t.status === 'error');
|
||||
const _activeTasks = _loadPrunedTasks().filter(t => t.status === 'running' || t.status === 'queued' || t.status === 'error');
|
||||
if (!_activeTasks.length) _clearCookbookNotif();
|
||||
} catch {}
|
||||
|
||||
@@ -1600,6 +1739,8 @@ export function _renderRunningTab() {
|
||||
const label = check.querySelector('.cookbook-task-done-label');
|
||||
if (label) label.textContent = _clearPillLabel(task);
|
||||
}
|
||||
const startNow = el.querySelector('.cookbook-task-start-now');
|
||||
if (startNow) startNow.style.display = (task.type === 'download' && task.status === 'queued') ? '' : 'none';
|
||||
const terminalDiag = _terminalServeDiagnosis(task, el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
|
||||
if (terminalDiag) _showDiagnosis(el, terminalDiag, el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
|
||||
}
|
||||
@@ -1626,6 +1767,7 @@ export function _renderRunningTab() {
|
||||
<span class="cookbook-task-type${(task.status === 'done' && task.type === 'download') ? ' cookbook-task-type-done' : ''}" data-type="${esc(task.type)}">${esc((task.status === 'done' && task.type === 'download') ? 'finished' : task.type)}</span>
|
||||
<span class="cookbook-task-name">${modelLogo(task.name)}${esc(task.name)}</span>
|
||||
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span><span class="cookbook-task-check" title="Clear" style="display:${_canClearTask(task) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">${esc(_clearPillLabel(task))}</span><span class="cookbook-task-clear-label">clear</span></span></span>
|
||||
<button type="button" class="cookbook-task-start-now" title="Start this queued download now" style="display:${(task.type === 'download' && task.status === 'queued') ? '' : 'none'}"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="8 5 19 12 8 19 8 5"/></svg><span>start now</span></button>
|
||||
<span class="cookbook-task-status ${_bdg.cls}"${_bdgTitle}>${esc(_bdg.text)}</span>
|
||||
<button class="cookbook-task-menu-btn" title="Actions">⋮</button>
|
||||
</div>
|
||||
@@ -1702,6 +1844,14 @@ export function _renderRunningTab() {
|
||||
});
|
||||
}
|
||||
|
||||
const _startNowBtn = el.querySelector('.cookbook-task-start-now');
|
||||
if (_startNowBtn) {
|
||||
_startNowBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_startQueuedDownload(task);
|
||||
});
|
||||
}
|
||||
|
||||
// Wire header click to collapse/expand output
|
||||
el.querySelector('.cookbook-task-header').addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
@@ -1986,13 +2136,20 @@ export function _renderRunningTab() {
|
||||
const badge = el.querySelector('.cookbook-task-status');
|
||||
if (badge) { badge.textContent = 'stopping...'; badge.className = 'cookbook-task-status cookbook-task-stopping'; }
|
||||
el.dataset.status = 'stopped';
|
||||
const outputText = el.querySelector('.cookbook-output-pre')?.textContent || task.output || '';
|
||||
// Drop the model endpoint so the picker stops listing it.
|
||||
if (task.type === 'serve' && task.payload) {
|
||||
const rawHost = task.remoteHost || 'localhost';
|
||||
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
||||
const portMatch = task.payload._cmd?.match(/--port\s+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
_removeEndpointByUrl(`http://${host}:${port}/v1`);
|
||||
_removeEndpointByUrl(_endpointUrlForTask(task, outputText));
|
||||
}
|
||||
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
|
||||
if (ollamaUnload) {
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: ollamaUnload }),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
// Gracefully stop (C-c, then kill the session) so it's fully down...
|
||||
try {
|
||||
@@ -2009,23 +2166,29 @@ export function _renderRunningTab() {
|
||||
|
||||
// Wire kill
|
||||
el.querySelector('.cookbook-task-action-kill').addEventListener('click', () => {
|
||||
const outputText = el.querySelector('.cookbook-output-pre')?.textContent || task.output || '';
|
||||
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
|
||||
if (ollamaUnload) {
|
||||
fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: ollamaUnload }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
|
||||
}).catch(() => {});
|
||||
if (task.type === 'serve' && task.payload) {
|
||||
const rawHost = task.remoteHost || 'localhost';
|
||||
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
||||
const portMatch = task.payload._cmd?.match(/--port\s+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
_removeEndpointByUrl(`http://${host}:${port}/v1`);
|
||||
const endpointUrl = _endpointUrlForTask(task, outputText);
|
||||
_removeEndpointByUrl(endpointUrl);
|
||||
const modelName = task.payload.model || task.name || '';
|
||||
if (modelName) {
|
||||
fetch('/api/model-endpoints', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(eps => {
|
||||
const ep = eps.find(e => e.name === modelName || (e.base_url && e.base_url.includes(':' + port)));
|
||||
const ep = eps.find(e => e.name === modelName || e.base_url === endpointUrl);
|
||||
if (ep) fetch(`/api/model-endpoints/${ep.id}`, { method: 'DELETE', credentials: 'same-origin' }).then(() => _refreshModelsAfterEndpointChange());
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -2168,6 +2331,33 @@ async function _reconnectTask(el, task) {
|
||||
fixes: [{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) }],
|
||||
};
|
||||
_showDiagnosis(el, diag, lastOutput);
|
||||
} else if (task.type === 'download') {
|
||||
const isDisk = /no space left|disk quota|enospc/i.test(lastOutput);
|
||||
const isNetwork = /connection|timeout|timed out|incompleteread|chunkedencoding|reset by peer|protocolerror|all connection attempts failed/i.test(lastOutput);
|
||||
const progressMatch = String(lastOutput || '').match(/(\d+)%\|/);
|
||||
const nearDone = progressMatch && Number(progressMatch[1]) >= 80;
|
||||
const diag = {
|
||||
message: isDisk
|
||||
? 'Download stopped because this server ran out of disk space.'
|
||||
: isNetwork
|
||||
? 'Download stopped after the HuggingFace connection was interrupted.'
|
||||
: nearDone
|
||||
? 'Download stopped near the end before the final completion marker was captured.'
|
||||
: 'Download stopped before HuggingFace reported completion.',
|
||||
suggestion: isDisk
|
||||
? 'Suggested action: free disk space, then retry the download. HuggingFace resumes incomplete files when possible.'
|
||||
: nearDone
|
||||
? 'Suggested action: retry the download. It may briefly look like it restarted while cached files are checked, then it should reuse incomplete files.'
|
||||
: 'Suggested action: retry the download. HuggingFace resumes incomplete files when possible.',
|
||||
fixes: [
|
||||
{ label: 'Retry download', action: () => _retryTask(el, task) },
|
||||
{ label: 'Copy last 50 lines', action: () => {
|
||||
const last = String(lastOutput || '').split('\n').slice(-50).join('\n');
|
||||
_copyText(last || 'No download log available.');
|
||||
} },
|
||||
],
|
||||
};
|
||||
_showDiagnosis(el, diag, lastOutput);
|
||||
}
|
||||
_showCookbookNotif(true);
|
||||
} else {
|
||||
@@ -2175,7 +2365,7 @@ async function _reconnectTask(el, task) {
|
||||
el.dataset.status = 'done';
|
||||
const badge = el.querySelector('.cookbook-task-status');
|
||||
if (badge) { badge.textContent = _statusLabel('done', task.type); badge.className = 'cookbook-task-status cookbook-task-done'; }
|
||||
const _chk = el.querySelector('.cookbook-task-check'); if (_chk && task.type !== 'download') _chk.style.display = '';
|
||||
const _chk = el.querySelector('.cookbook-task-check'); if (_chk) _chk.style.display = '';
|
||||
const _sb = el.querySelector('.cookbook-task-serve-btn'); if (_sb) _sb.style.display = '';
|
||||
_showCookbookNotif();
|
||||
_refreshDepsAfterInstall(task);
|
||||
@@ -2804,13 +2994,24 @@ async function _pollBackgroundStatus() {
|
||||
const updates = {};
|
||||
const nextStatus = live.status === 'completed'
|
||||
? 'done'
|
||||
: (live.status === 'error' ? 'error' : null);
|
||||
: (live.status === 'error'
|
||||
? 'error'
|
||||
: (live.status === 'stopped' ? (task.type === 'download' ? 'crashed' : 'stopped') : null));
|
||||
if (nextStatus && task.status !== nextStatus) {
|
||||
updates.status = nextStatus;
|
||||
if (nextStatus === 'done' && task.payload?._dep) completedDeps.push(task);
|
||||
}
|
||||
if ((live.status === 'running' || live.status === 'ready') && task.status !== live.status) {
|
||||
updates.status = live.status === 'ready' ? 'ready' : 'running';
|
||||
}
|
||||
if (live.progress && live.progress !== task.progress) updates.progress = live.progress;
|
||||
if (live.output_tail && live.output_tail !== task.output) updates.output = live.output_tail;
|
||||
if (live.output_tail) {
|
||||
const previous = String(task.output || '');
|
||||
const tail = String(live.output_tail || '');
|
||||
if (tail && !previous.endsWith(tail)) {
|
||||
updates.output = `${previous ? `${previous}\n` : ''}${tail}`.slice(-5000);
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length) {
|
||||
Object.assign(task, updates);
|
||||
changed = true;
|
||||
|
||||
@@ -29,6 +29,7 @@ import * as Modals from './modalManager.js';
|
||||
let _htmlPreviewActive = false; // true when inline HTML preview iframe is showing
|
||||
let _emailAccountsCache = null;
|
||||
let _emailAccountsCacheAt = 0;
|
||||
let _emailHeaderManualExpandUntil = 0;
|
||||
|
||||
// Diff mode state
|
||||
let _diffModeActive = false;
|
||||
@@ -2308,6 +2309,53 @@ import * as Modals from './modalManager.js';
|
||||
return r && r.style.display !== 'none' ? r : null;
|
||||
}
|
||||
|
||||
function _captureEmailBodyFocusState() {
|
||||
const rich = _emailRichbodyActive();
|
||||
const ta = document.getElementById('doc-editor-textarea');
|
||||
const active = document.activeElement;
|
||||
if (rich && (active === rich || rich.contains(active))) {
|
||||
const sel = window.getSelection();
|
||||
const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
|
||||
return {
|
||||
type: 'rich',
|
||||
range: range && rich.contains(range.commonAncestorContainer) ? range.cloneRange() : null,
|
||||
};
|
||||
}
|
||||
if (ta && active === ta) {
|
||||
return {
|
||||
type: 'textarea',
|
||||
start: ta.selectionStart,
|
||||
end: ta.selectionEnd,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _restoreEmailBodyFocusState(state) {
|
||||
if (!state) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (state.type === 'rich') {
|
||||
const rich = _emailRichbodyActive();
|
||||
if (!rich) return;
|
||||
rich.focus({ preventScroll: true });
|
||||
if (state.range) {
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(state.range);
|
||||
}
|
||||
}
|
||||
} else if (state.type === 'textarea') {
|
||||
const ta = document.getElementById('doc-editor-textarea');
|
||||
if (!ta) return;
|
||||
ta.focus({ preventScroll: true });
|
||||
if (Number.isFinite(state.start) && Number.isFinite(state.end)) {
|
||||
try { ta.setSelectionRange(state.start, state.end); } catch (_) {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _stripEmailReplyQuoteText(text) {
|
||||
const original = String(text || '');
|
||||
if (!original) return { body: '', stripped: false };
|
||||
@@ -2369,6 +2417,48 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
}
|
||||
|
||||
function _syncEmailHeaderSummary() {
|
||||
const to = document.getElementById('doc-email-to')?.value?.trim() || 'No recipient';
|
||||
const subject = document.getElementById('doc-email-subject')?.value?.trim() || 'No subject';
|
||||
const cc = document.getElementById('doc-email-cc')?.value?.trim() || '';
|
||||
const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || '';
|
||||
const summary = document.getElementById('doc-email-collapse-summary');
|
||||
if (!summary) return;
|
||||
const extras = [];
|
||||
if (cc) extras.push('Cc');
|
||||
if (bcc) extras.push('Bcc');
|
||||
summary.textContent = `${to} · ${subject}${extras.length ? ` · ${extras.join('/')}` : ''}`;
|
||||
summary.title = summary.textContent;
|
||||
}
|
||||
|
||||
function _setEmailHeaderCollapsed(collapsed, { manual = true } = {}) {
|
||||
const header = document.getElementById('doc-email-header');
|
||||
const btn = document.getElementById('doc-email-collapse-btn');
|
||||
if (!header) return;
|
||||
if (window.innerWidth > 768) collapsed = false;
|
||||
header.classList.toggle('doc-email-header-collapsed', !!collapsed);
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', String(!collapsed));
|
||||
btn.title = collapsed ? 'Show email fields' : 'Hide email fields';
|
||||
}
|
||||
const doc = activeDocId && docs.get(activeDocId);
|
||||
if (doc && manual) doc._emailHeaderCollapsed = !!collapsed;
|
||||
if (manual && !collapsed) _emailHeaderManualExpandUntil = Date.now() + 1400;
|
||||
_syncEmailHeaderSummary();
|
||||
}
|
||||
|
||||
function _shouldAutoCollapseEmailHeader() {
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
|
||||
function _maybeAutoCollapseEmailHeader() {
|
||||
const doc = activeDocId && docs.get(activeDocId);
|
||||
if (!doc || doc.language !== 'email') return;
|
||||
if (Date.now() < _emailHeaderManualExpandUntil) return;
|
||||
if (document.activeElement?.closest?.('#doc-email-fields')) return;
|
||||
if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false });
|
||||
}
|
||||
|
||||
function _showEmailFields(doc) {
|
||||
const emailHeader = document.getElementById('doc-email-header');
|
||||
const emailActions = document.getElementById('doc-email-actions');
|
||||
@@ -2407,6 +2497,7 @@ import * as Modals from './modalManager.js';
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (toInput) toInput.value = fields.to;
|
||||
if (subjectInput) subjectInput.value = fields.subject;
|
||||
_setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false });
|
||||
if (subjectInput && !subjectInput._emailTabBodyBound) {
|
||||
subjectInput._emailTabBodyBound = true;
|
||||
subjectInput.addEventListener('keydown', (e) => {
|
||||
@@ -2548,6 +2639,7 @@ import * as Modals from './modalManager.js';
|
||||
if (ccRow) ccRow.style.display = hasCcBcc ? '' : 'none';
|
||||
if (bccRow) bccRow.style.display = hasCcBcc ? '' : 'none';
|
||||
if (ccToggle) ccToggle.style.display = hasCcBcc ? 'none' : '';
|
||||
_syncEmailHeaderSummary();
|
||||
}
|
||||
|
||||
async function _uploadComposeFiles(files) {
|
||||
@@ -3062,19 +3154,22 @@ import * as Modals from './modalManager.js';
|
||||
saveCurrentToMap();
|
||||
const doc = docs.get(docId);
|
||||
const snapshot = { id: docId, doc: { ...doc } };
|
||||
saveDocument({ silent: true }).catch(() => {});
|
||||
const wasActive = activeDocId === docId;
|
||||
if (wasActive) saveDocument({ silent: true }).catch(() => {});
|
||||
|
||||
const visibleBefore = _visibleDocIdsForCurrentSession();
|
||||
const idx = visibleBefore.indexOf(docId);
|
||||
docs.delete(docId);
|
||||
if (activeDocId === docId) activeDocId = null;
|
||||
if (wasActive) activeDocId = null;
|
||||
|
||||
const remaining = visibleBefore.filter(id => id !== docId && docs.has(id));
|
||||
const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null;
|
||||
if (nextId) {
|
||||
switchToDoc(nextId);
|
||||
} else {
|
||||
closePanel();
|
||||
if (wasActive) {
|
||||
const remaining = visibleBefore.filter(id => id !== docId && docs.has(id));
|
||||
const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null;
|
||||
if (nextId) {
|
||||
switchToDoc(nextId);
|
||||
} else {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
renderTabs();
|
||||
_syncDocIndicator();
|
||||
@@ -3748,25 +3843,31 @@ import * as Modals from './modalManager.js';
|
||||
</div>
|
||||
<div class="doc-tab-bar" id="doc-tab-bar"></div>
|
||||
<div id="doc-email-header" class="doc-email-header" style="display:none">
|
||||
<div class="email-field" style="position:relative">
|
||||
<label>To</label>
|
||||
<input type="text" id="doc-email-to" placeholder="recipient@example.com" autocomplete="off" />
|
||||
<div id="doc-email-to-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
<button type="button" id="doc-email-show-cc" class="email-cc-toggle" title="Show Cc/Bcc">Cc</button>
|
||||
<button type="button" id="doc-email-collapse-btn" class="doc-email-collapse-btn" title="Hide email fields" aria-expanded="true">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg>
|
||||
<span id="doc-email-collapse-summary" class="doc-email-collapse-summary">No recipient · No subject</span>
|
||||
</button>
|
||||
<div id="doc-email-fields" class="doc-email-fields">
|
||||
<div class="email-field" style="position:relative">
|
||||
<label>To</label>
|
||||
<input type="text" id="doc-email-to" placeholder="recipient@example.com" autocomplete="off" />
|
||||
<div id="doc-email-to-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
<button type="button" id="doc-email-show-cc" class="email-cc-toggle" title="Show Cc/Bcc">Cc</button>
|
||||
</div>
|
||||
<div class="email-field" id="doc-email-cc-row" style="display:none;position:relative">
|
||||
<label>Cc</label>
|
||||
<input type="text" id="doc-email-cc" placeholder="cc@example.com" autocomplete="off" />
|
||||
<div id="doc-email-cc-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
</div>
|
||||
<div class="email-field" id="doc-email-bcc-row" style="display:none;position:relative">
|
||||
<label>Bcc</label>
|
||||
<input type="text" id="doc-email-bcc" placeholder="bcc@example.com" autocomplete="off" />
|
||||
<div id="doc-email-bcc-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
</div>
|
||||
<div class="email-field"><label>Subject</label><input type="text" id="doc-email-subject" placeholder="Subject" /></div>
|
||||
<div id="doc-email-attachments" class="email-attachments" style="display:none"></div>
|
||||
<div id="doc-email-compose-atts" class="email-compose-atts" style="display:none"></div>
|
||||
</div>
|
||||
<div class="email-field" id="doc-email-cc-row" style="display:none;position:relative">
|
||||
<label>Cc</label>
|
||||
<input type="text" id="doc-email-cc" placeholder="cc@example.com" autocomplete="off" />
|
||||
<div id="doc-email-cc-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
</div>
|
||||
<div class="email-field" id="doc-email-bcc-row" style="display:none;position:relative">
|
||||
<label>Bcc</label>
|
||||
<input type="text" id="doc-email-bcc" placeholder="bcc@example.com" autocomplete="off" />
|
||||
<div id="doc-email-bcc-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
</div>
|
||||
<div class="email-field"><label>Subject</label><input type="text" id="doc-email-subject" placeholder="Subject" /></div>
|
||||
<div id="doc-email-attachments" class="email-attachments" style="display:none"></div>
|
||||
<div id="doc-email-compose-atts" class="email-compose-atts" style="display:none"></div>
|
||||
<input type="hidden" id="doc-email-in-reply-to" />
|
||||
<input type="hidden" id="doc-email-references" />
|
||||
<input type="hidden" id="doc-email-source-uid" />
|
||||
@@ -4308,6 +4409,33 @@ import * as Modals from './modalManager.js';
|
||||
});
|
||||
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply);
|
||||
|
||||
const collapseBtn = document.getElementById('doc-email-collapse-btn');
|
||||
if (collapseBtn && !collapseBtn._emailCollapseWired) {
|
||||
collapseBtn._emailCollapseWired = true;
|
||||
collapseBtn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const focusState = _captureEmailBodyFocusState();
|
||||
const header = document.getElementById('doc-email-header');
|
||||
const nextCollapsed = !header?.classList.contains('doc-email-header-collapsed');
|
||||
_setEmailHeaderCollapsed(nextCollapsed);
|
||||
if (!nextCollapsed) _restoreEmailBodyFocusState(focusState);
|
||||
});
|
||||
collapseBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
['doc-email-to', 'doc-email-cc', 'doc-email-bcc', 'doc-email-subject'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', _syncEmailHeaderSummary);
|
||||
document.getElementById(id)?.addEventListener('focus', () => _setEmailHeaderCollapsed(false, { manual: false }));
|
||||
});
|
||||
document.getElementById('doc-email-richbody')?.addEventListener('focus', _maybeAutoCollapseEmailHeader);
|
||||
if (window.visualViewport && !window._docEmailViewportCollapseBound) {
|
||||
window._docEmailViewportCollapseBound = true;
|
||||
window.visualViewport.addEventListener('resize', _maybeAutoCollapseEmailHeader);
|
||||
}
|
||||
|
||||
// Split-button caret toggles the send-options menu (drops up).
|
||||
document.getElementById('doc-email-send-caret')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -4350,11 +4478,13 @@ import * as Modals from './modalManager.js';
|
||||
|
||||
// Cc/Bcc toggle
|
||||
document.getElementById('doc-email-show-cc')?.addEventListener('click', () => {
|
||||
_setEmailHeaderCollapsed(false, { manual: false });
|
||||
const ccRow = document.getElementById('doc-email-cc-row');
|
||||
const bccRow = document.getElementById('doc-email-bcc-row');
|
||||
if (ccRow) ccRow.style.display = '';
|
||||
if (bccRow) bccRow.style.display = '';
|
||||
document.getElementById('doc-email-show-cc').style.display = 'none';
|
||||
_syncEmailHeaderSummary();
|
||||
});
|
||||
|
||||
// Autocomplete for To / Cc / Bcc — typed fragment after the last
|
||||
|
||||
@@ -27,6 +27,183 @@ const API_BASE = window.location.origin;
|
||||
let _emailUnreadChipClickWired = false;
|
||||
let _libLoadSeq = 0;
|
||||
let _libFolderSeq = 0;
|
||||
let _libSearchSeq = 0;
|
||||
let _libSearchHadResults = false;
|
||||
let _activeEmailReaderForSelectAll = null;
|
||||
|
||||
function _isEmailTypingTarget(t) {
|
||||
return !!(t && (
|
||||
t.tagName === 'INPUT' ||
|
||||
t.tagName === 'TEXTAREA' ||
|
||||
t.tagName === 'SELECT' ||
|
||||
t.isContentEditable
|
||||
));
|
||||
}
|
||||
|
||||
function _selectEmailReaderContents(reader) {
|
||||
if (!reader || !reader.isConnected) return false;
|
||||
const hiddenModal = reader.closest('.modal.hidden');
|
||||
if (hiddenModal) return false;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(reader);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _markEmailReaderActive(reader) {
|
||||
if (!reader) return;
|
||||
_activeEmailReaderForSelectAll = reader;
|
||||
if (reader.dataset.selectAllWired === '1') return;
|
||||
reader.dataset.selectAllWired = '1';
|
||||
reader.addEventListener('pointerdown', () => { _activeEmailReaderForSelectAll = reader; }, true);
|
||||
reader.addEventListener('focusin', () => { _activeEmailReaderForSelectAll = reader; }, true);
|
||||
}
|
||||
|
||||
const _COPY_EMAIL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
|
||||
function _decodeAttrValue(v) {
|
||||
const tmp = document.createElement('textarea');
|
||||
tmp.innerHTML = v || '';
|
||||
return tmp.value;
|
||||
}
|
||||
|
||||
function _emailAddressFromRecipientText(text) {
|
||||
const raw = String(text || '').trim();
|
||||
const angle = raw.match(/<\s*([^<>@\s]+@[^<>\s]+)\s*>/);
|
||||
if (angle) return angle[1].trim();
|
||||
const any = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
|
||||
return any ? any[0].trim() : raw;
|
||||
}
|
||||
|
||||
function _splitRecipientList(raw) {
|
||||
const out = [];
|
||||
let cur = '';
|
||||
let quote = false;
|
||||
let angle = false;
|
||||
const s = String(raw || '');
|
||||
for (let i = 0; i < s.length; i += 1) {
|
||||
const ch = s[i];
|
||||
if (ch === '"' && s[i - 1] !== '\\') quote = !quote;
|
||||
else if (ch === '<' && !quote) angle = true;
|
||||
else if (ch === '>' && !quote) angle = false;
|
||||
|
||||
if (ch === ',' && !quote && !angle) {
|
||||
const part = cur.trim();
|
||||
if (part) out.push(part);
|
||||
cur = '';
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
const tail = cur.trim();
|
||||
if (tail) out.push(tail);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function _copyTextToClipboard(text) {
|
||||
const value = String(text || '');
|
||||
if (!value) return false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = value;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
ta.style.top = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return !!ok;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _recipientChipHtml(full, label, extraClass = '') {
|
||||
const fullText = String(full || '').trim();
|
||||
const addr = _emailAddressFromRecipientText(fullText);
|
||||
const labelText = String(label || addr || fullText || '').trim();
|
||||
const cls = `recipient-chip${extraClass ? ` ${extraClass}` : ''}`;
|
||||
return `<span class="${cls}" data-full="${_esc(fullText || labelText)}" data-email="${_esc(addr)}" title="Click for details"><span class="recipient-chip-label">${_esc(labelText)}</span><button type="button" class="recipient-chip-copy" title="Copy email" aria-label="Copy email" hidden>${_COPY_EMAIL_ICON}</button></span>`;
|
||||
}
|
||||
|
||||
function _wireRecipientChips(root) {
|
||||
if (!root || root.dataset.recipientChipsWired === '1') return;
|
||||
root.dataset.recipientChipsWired = '1';
|
||||
root.addEventListener('click', async (ev) => {
|
||||
const copyBtn = ev.target.closest?.('.recipient-chip-copy');
|
||||
if (copyBtn && root.contains(copyBtn)) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const chip = copyBtn.closest('.recipient-chip');
|
||||
const email = chip?.dataset.email || _emailAddressFromRecipientText(_decodeAttrValue(chip?.dataset.full || ''));
|
||||
if (!email) return;
|
||||
try {
|
||||
const copied = await _copyTextToClipboard(email);
|
||||
if (!copied) throw new Error('copy failed');
|
||||
copyBtn.classList.add('copied');
|
||||
copyBtn.title = 'Copied';
|
||||
showToast?.('Email copied');
|
||||
setTimeout(() => {
|
||||
copyBtn.classList.remove('copied');
|
||||
copyBtn.title = 'Copy email';
|
||||
}, 900);
|
||||
} catch (_) {
|
||||
showToast?.('Copy failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const chip = ev.target.closest?.('.recipient-chip');
|
||||
if (!chip || !root.contains(chip)) return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const label = chip.querySelector('.recipient-chip-label');
|
||||
const copy = chip.querySelector('.recipient-chip-copy');
|
||||
if (chip.classList.contains('expanded')) {
|
||||
chip.classList.remove('expanded');
|
||||
if (label) label.textContent = chip.dataset.name || label.textContent;
|
||||
if (copy) copy.hidden = true;
|
||||
} else {
|
||||
if (!chip.dataset.name && label) chip.dataset.name = label.textContent.trim();
|
||||
chip.classList.add('expanded');
|
||||
const expandedText = _decodeAttrValue(chip.dataset.full || '').trim()
|
||||
|| chip.dataset.name
|
||||
|| chip.dataset.email
|
||||
|| label?.textContent?.trim()
|
||||
|| '';
|
||||
if (label && expandedText) label.textContent = expandedText;
|
||||
if (copy) copy.hidden = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _emailReaderForSelectAllTarget(target) {
|
||||
if (_isEmailTypingTarget(target)) return null;
|
||||
const direct = target?.closest?.('.email-card-reader, #email-lib-modal .doclib-card.doclib-card-expanded');
|
||||
if (direct) return direct.querySelector?.('.email-card-reader') || direct;
|
||||
const expanded = document.querySelector('#email-lib-modal:not(.hidden) .doclib-card.doclib-card-expanded .email-card-reader');
|
||||
if (expanded) return expanded;
|
||||
return _activeEmailReaderForSelectAll;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || String(e.key || '').toLowerCase() !== 'a') return;
|
||||
const reader = _emailReaderForSelectAllTarget(e.target);
|
||||
if (!_selectEmailReaderContents(reader)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation?.();
|
||||
}, true);
|
||||
|
||||
function _syncEmailReadState(uid, isRead = true) {
|
||||
if (uid == null) return;
|
||||
@@ -1047,10 +1224,26 @@ export function openEmailLibrary(opts = {}) {
|
||||
_bulkAction('delete');
|
||||
});
|
||||
|
||||
const selectExpandedEmailText = () => {
|
||||
const expanded = document.querySelector('#email-lib-modal .doclib-card.doclib-card-expanded');
|
||||
const reader = expanded?.querySelector('.email-card-reader') || expanded;
|
||||
return _selectEmailReaderContents(reader);
|
||||
};
|
||||
|
||||
// ESC to close + Arrow nav + Delete on the selected / currently-expanded email.
|
||||
state._libEscHandler = (e) => {
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
if (!modal || modal.classList.contains('hidden')) return;
|
||||
if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'a') {
|
||||
const t = e.target;
|
||||
if (_isEmailTypingTarget(t)) return;
|
||||
if (selectExpandedEmailText()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1067,7 +1260,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
}
|
||||
// Don't hijack arrows / delete while the user is typing somewhere.
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
if (_isEmailTypingTarget(t)) return;
|
||||
const isDeleteKey = e.key === 'Delete' || e.key === 'Backspace';
|
||||
if (isDeleteKey && state._selectMode && state._selectedUids.size > 0) {
|
||||
e.preventDefault();
|
||||
@@ -1193,6 +1386,23 @@ function _makeDraggable(content, modal, fsClass) {
|
||||
fsClass,
|
||||
skipSelector: '.close-btn, .modal-close',
|
||||
enableLeftDock: true, // park the email on the left while replying on the right
|
||||
onDragStart: ({ rect }) => {
|
||||
if (!modal.classList.contains('email-snap-left')) return;
|
||||
modal.classList.remove('email-snap-left');
|
||||
_clearEmailDocumentSplit();
|
||||
content.style.position = 'fixed';
|
||||
content.style.left = `${Math.round(rect.left)}px`;
|
||||
content.style.top = `${Math.round(rect.top)}px`;
|
||||
content.style.right = '';
|
||||
content.style.bottom = '';
|
||||
content.style.width = `${Math.max(420, Math.round(rect.width || 560))}px`;
|
||||
content.style.maxWidth = '';
|
||||
content.style.height = `${Math.max(320, Math.round(rect.height || 620))}px`;
|
||||
content.style.maxHeight = '85vh';
|
||||
content.style.borderRadius = '';
|
||||
content.style.transform = 'none';
|
||||
content.style.margin = '0';
|
||||
},
|
||||
onEnterFullscreen: fsClass ? enterFullscreen : null,
|
||||
onExitFullscreen: fsClass ? exitFullscreen : null,
|
||||
});
|
||||
@@ -1316,22 +1526,43 @@ function _crossFolderCandidates() {
|
||||
}
|
||||
|
||||
async function _doSearch() {
|
||||
const seq = ++_libSearchSeq;
|
||||
const q = state._libSearch.trim();
|
||||
if (q.length < 2) {
|
||||
// Empty or too short — show regular loaded emails
|
||||
// Empty or too short — restore the normal folder if a previous search
|
||||
// had replaced the grid contents.
|
||||
if (_libSearchHadResults) {
|
||||
_libSearchHadResults = false;
|
||||
state._libOffset = 0;
|
||||
await _loadEmails({ useCache: true });
|
||||
return;
|
||||
}
|
||||
_renderGrid();
|
||||
return;
|
||||
}
|
||||
const grid = document.getElementById('email-lib-grid');
|
||||
if (!grid) return;
|
||||
const sp = _renderEmailLoading(grid);
|
||||
const accountAtStart = state._libAccountId || '';
|
||||
const folderAtStart = state._libFolder || 'INBOX';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(state._libFolder)}${_acct()}&q=${encodeURIComponent(q)}&limit=100`);
|
||||
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(folderAtStart)}${accountQS}&q=${encodeURIComponent(q)}&limit=100`);
|
||||
const data = await res.json();
|
||||
sp.destroy();
|
||||
if (
|
||||
seq !== _libSearchSeq ||
|
||||
q !== state._libSearch.trim() ||
|
||||
accountAtStart !== (state._libAccountId || '') ||
|
||||
folderAtStart !== (state._libFolder || 'INBOX')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
const results = data.emails || [];
|
||||
_libSearchHadResults = true;
|
||||
state._libEmails = results; // temporarily replace with search results
|
||||
_renderGrid();
|
||||
|
||||
@@ -1895,8 +2126,9 @@ function _syncCardNavArrows(card) {
|
||||
}
|
||||
|
||||
const _emailReadPrefetching = new Set();
|
||||
let _emailReadPrefetchTimer = null;
|
||||
|
||||
function _prefetchAdjacentEmails(card, count = 3) {
|
||||
function _prefetchAdjacentEmails(card, count = 1) {
|
||||
if (!card || state._libFolder === '__scheduled__') return;
|
||||
const grid = card.closest('.doclib-grid');
|
||||
if (!grid) return;
|
||||
@@ -1910,16 +2142,19 @@ function _prefetchAdjacentEmails(card, count = 3) {
|
||||
if (targets.length < count) {
|
||||
for (let i = 1; targets.length < count && cards[idx - i]; i++) targets.push(cards[idx - i]);
|
||||
}
|
||||
for (const target of targets) {
|
||||
const uid = target.dataset.uid;
|
||||
if (!uid) continue;
|
||||
const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
|
||||
if (_emailReadPrefetching.has(key)) continue;
|
||||
const target = targets.find(t => t?.dataset?.uid);
|
||||
const uid = target?.dataset?.uid;
|
||||
if (!uid) return;
|
||||
const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
|
||||
if (_emailReadPrefetching.has(key) || _emailReadPrefetching.size > 0) return;
|
||||
if (_emailReadPrefetchTimer) clearTimeout(_emailReadPrefetchTimer);
|
||||
_emailReadPrefetchTimer = setTimeout(() => {
|
||||
_emailReadPrefetchTimer = null;
|
||||
_emailReadPrefetching.add(key);
|
||||
fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&mark_seen=false`)
|
||||
.catch(() => {})
|
||||
.finally(() => _emailReadPrefetching.delete(key));
|
||||
}
|
||||
}, 900);
|
||||
}
|
||||
|
||||
async function _toggleCardPreview(card, em) {
|
||||
@@ -1987,6 +2222,7 @@ async function _toggleCardPreview(card, em) {
|
||||
loadingWrap.appendChild(sp.element);
|
||||
reader.appendChild(loadingWrap);
|
||||
card.appendChild(reader);
|
||||
_markEmailReaderActive(reader);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
|
||||
@@ -2032,16 +2268,16 @@ async function _toggleCardPreview(card, em) {
|
||||
// Build recipient chip group from a comma-separated address list
|
||||
const buildRecipients = (str) => {
|
||||
if (!str) return '';
|
||||
const addrs = str.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const addrs = _splitRecipientList(str);
|
||||
if (addrs.length === 0) return '';
|
||||
return addrs.map(a => {
|
||||
const name = _extractName(a);
|
||||
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// Build the From chip too — single chip with name, click reveals address
|
||||
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} <${_esc(data.from_address)}>" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
|
||||
reader.innerHTML = `
|
||||
<div class="email-reader-header">
|
||||
@@ -2069,6 +2305,7 @@ async function _toggleCardPreview(card, em) {
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(reader);
|
||||
reader.classList.remove('email-card-reader-loading');
|
||||
reader.style.minHeight = '';
|
||||
|
||||
@@ -2218,32 +2455,9 @@ async function _toggleCardPreview(card, em) {
|
||||
_showCachedSummary(reader, data.cached_summary, sumBtn);
|
||||
}
|
||||
|
||||
// Event delegation for recipient chip clicks (toggle expand)
|
||||
reader.addEventListener('click', (ev) => {
|
||||
const chip = ev.target.closest('.recipient-chip');
|
||||
if (chip && reader.contains(chip)) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const full = chip.getAttribute('data-full') || '';
|
||||
if (chip.classList.contains('expanded')) {
|
||||
chip.classList.remove('expanded');
|
||||
const name = chip.getAttribute('data-name');
|
||||
if (name != null) chip.textContent = name;
|
||||
} else {
|
||||
if (!chip.hasAttribute('data-name')) {
|
||||
chip.setAttribute('data-name', chip.textContent.trim());
|
||||
}
|
||||
chip.classList.add('expanded');
|
||||
// Decode HTML entities from the data-full attribute
|
||||
const tmp = document.createElement('textarea');
|
||||
tmp.innerHTML = full;
|
||||
chip.textContent = tmp.value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Always stop bubbling so the card's click doesn't fire
|
||||
ev.stopPropagation();
|
||||
});
|
||||
_wireRecipientChips(reader);
|
||||
// Always stop bubbling so the card's click doesn't fire while reading.
|
||||
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
|
||||
} catch (e) {
|
||||
reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Failed to load email</div>`;
|
||||
}
|
||||
@@ -3716,6 +3930,7 @@ async function _openEmailAsTab(em, folder) {
|
||||
// Fetch + render the email body using the exact same template as
|
||||
// _toggleCardPreview so the visuals match perfectly.
|
||||
const reader = modal.querySelector('.email-card-reader');
|
||||
_markEmailReaderActive(reader);
|
||||
const sp = spinnerModule.createWhirlpool(28);
|
||||
const loading = modal.querySelector('.email-reader-tab-loading');
|
||||
if (loading) loading.appendChild(sp.element);
|
||||
@@ -3729,12 +3944,12 @@ async function _openEmailAsTab(em, folder) {
|
||||
_syncEmailReadState(em.uid, true);
|
||||
const buildChips = (str) => {
|
||||
if (!str) return '';
|
||||
return str.split(',').map(s => s.trim()).filter(Boolean).map(a => {
|
||||
return _splitRecipientList(str).map(a => {
|
||||
const name = _extractName(a);
|
||||
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} <${_esc(data.from_address)}>" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
let attsHtml = '';
|
||||
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
|
||||
reader.innerHTML = `
|
||||
@@ -3763,6 +3978,8 @@ async function _openEmailAsTab(em, folder) {
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(reader);
|
||||
_wireRecipientChips(reader);
|
||||
try { _wireAttachmentHandlers(reader, useFolder); } catch {}
|
||||
const attsWrap = reader.querySelector('.email-reader-atts-wrap');
|
||||
if (attsWrap) {
|
||||
@@ -3875,18 +4092,19 @@ async function _openEmailWindow(em, folder) {
|
||||
// standalone viewer looks/feels exactly like a real email view.
|
||||
const _chipsFor = (addrs) => {
|
||||
if (!addrs) return '';
|
||||
const list = addrs.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const list = _splitRecipientList(addrs);
|
||||
return list.map(a => {
|
||||
const name = _extractName(a);
|
||||
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} <${_esc(data.from_address)}>" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
let attsHtml = '';
|
||||
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
|
||||
// Repurpose bodyEl as a full email-card-reader so the inline reader's
|
||||
// CSS applies (sized header, action buttons in two rows, etc.).
|
||||
bodyEl.classList.add('email-card-reader');
|
||||
_markEmailReaderActive(bodyEl);
|
||||
bodyEl.style.padding = '0';
|
||||
bodyEl.innerHTML = `
|
||||
<div class="email-reader-header">
|
||||
@@ -3914,6 +4132,8 @@ async function _openEmailWindow(em, folder) {
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(bodyEl);
|
||||
_wireRecipientChips(bodyEl);
|
||||
// Wire all the same action handlers the inline reader has.
|
||||
try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {}
|
||||
const attsWrap = bodyEl.querySelector('.email-reader-atts-wrap');
|
||||
@@ -3986,11 +4206,22 @@ async function _swapReaderToUid(reader, uid, folder) {
|
||||
if (headerMeta) {
|
||||
const subj = data.subject || '(no subject)';
|
||||
const date = data.date ? new Date(data.date).toLocaleString() : '';
|
||||
const chipsFor = (addrs) => {
|
||||
if (!addrs) return '';
|
||||
return _splitRecipientList(addrs).map(a => {
|
||||
const name = _extractName(a);
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
headerMeta.innerHTML = `
|
||||
<div class="email-reader-meta-row"><strong>Subject:</strong> ${_esc(subj)}</div>
|
||||
<div class="email-reader-meta-row"><strong>From:</strong> ${_esc(data.from_name || data.from_address)} <${_esc(data.from_address)}></div>
|
||||
<div class="email-reader-meta-row"><strong>From:</strong><span class="recipient-chips">${fromChip}</span></div>
|
||||
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${chipsFor(data.to)}</span></div>` : ''}
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${chipsFor(data.cc)}</span></div>` : ''}
|
||||
${date ? `<div class="email-reader-meta-row"><strong>Date:</strong> ${_esc(date)}</div>` : ''}
|
||||
`;
|
||||
_wireRecipientChips(reader);
|
||||
}
|
||||
// Refresh the attachments block to match the new email. Build fresh HTML
|
||||
// and either replace the existing block, remove it (if the new email has
|
||||
@@ -4227,6 +4458,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
const _deleteForeverIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="14" y2="15"/><line x1="14" y1="11" x2="10" y2="15"/></svg>';
|
||||
const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
const _newTabIcon = '<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 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
||||
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
|
||||
const closeAndRemove = async () => {
|
||||
// Pick the next neighbour BEFORE we re-render so we know which email to
|
||||
@@ -4309,6 +4541,24 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
_renderGrid();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: em.is_answered ? 'Not Done' : 'Done',
|
||||
icon: _checkIcon,
|
||||
action: async () => {
|
||||
const newState = !em.is_answered;
|
||||
em.is_answered = newState;
|
||||
if (newState) _syncEmailReadState(em.uid, true);
|
||||
try {
|
||||
if (newState) {
|
||||
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else {
|
||||
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
}
|
||||
} catch (e) { console.error('Failed to toggle done:', e); }
|
||||
_renderGrid();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Archive',
|
||||
icon: _archIcon,
|
||||
@@ -4450,7 +4700,7 @@ function _showCardMenu(em, anchor) {
|
||||
const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null;
|
||||
const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered;
|
||||
actions.push({
|
||||
label: _currentlyDone ? 'Mark Not Done' : 'Mark Done',
|
||||
label: _currentlyDone ? 'Not Done' : 'Done',
|
||||
icon: _checkIcon,
|
||||
action: async () => {
|
||||
const card = anchor.closest('.doclib-card');
|
||||
@@ -4579,7 +4829,9 @@ function _showBulkActionsMenu(anchor) {
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||
const _readIco = '<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="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
|
||||
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const items = [
|
||||
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
|
||||
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
|
||||
{ label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') },
|
||||
];
|
||||
@@ -4649,32 +4901,78 @@ async function _bulkAction(action) {
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
for (const uid of uids) {
|
||||
try {
|
||||
if (action === 'archive') {
|
||||
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else if (action === 'delete') {
|
||||
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
} else if (action === 'read' || action === 'unread') {
|
||||
const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
|
||||
const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (!res.ok || data?.success === false) {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
_syncEmailReadState(uid, action === 'read');
|
||||
}
|
||||
} catch (e) {
|
||||
if (action === 'read' || action === 'unread') failedReadSync += 1;
|
||||
console.error(`Failed to ${action} ${uid}:`, e);
|
||||
const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null;
|
||||
const actionsBtn = document.getElementById('email-lib-bulk-actions');
|
||||
const cancelBtn = document.getElementById('email-lib-bulk-cancel');
|
||||
const selectAll = document.getElementById('email-lib-select-all');
|
||||
const countEl = document.getElementById('email-lib-selected-count');
|
||||
const originalDeleteHtml = deleteBtn?.innerHTML || '';
|
||||
const originalCountText = countEl?.textContent || '';
|
||||
let busySpinner = null;
|
||||
if (action === 'delete') {
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.classList.add('email-bulk-loading');
|
||||
deleteBtn.innerHTML = '<span class="email-bulk-loading-label">Deleting</span>';
|
||||
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
|
||||
const spEl = busySpinner.createElement();
|
||||
spEl.classList.add('email-bulk-whirlpool');
|
||||
deleteBtn.appendChild(spEl);
|
||||
busySpinner.start();
|
||||
}
|
||||
if (actionsBtn) actionsBtn.disabled = true;
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
if (selectAll) selectAll.disabled = true;
|
||||
if (countEl) countEl.textContent = `Deleting ${uids.length}...`;
|
||||
}
|
||||
|
||||
if (action === 'archive' || action === 'delete') {
|
||||
await _animateEmailCardRemoval(uids);
|
||||
const removed = new Set(uids.map(uid => String(uid)));
|
||||
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
|
||||
try {
|
||||
for (const uid of uids) {
|
||||
try {
|
||||
if (action === 'archive') {
|
||||
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else if (action === 'delete') {
|
||||
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
} else if (action === 'done') {
|
||||
const em = state._libEmails.find(e => e.uid === uid);
|
||||
if (em) {
|
||||
em.is_answered = true;
|
||||
em.is_read = true;
|
||||
}
|
||||
await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else if (action === 'read' || action === 'unread') {
|
||||
const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
|
||||
const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (!res.ok || data?.success === false) {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
_syncEmailReadState(uid, action === 'read');
|
||||
}
|
||||
} catch (e) {
|
||||
if (action === 'read' || action === 'unread') failedReadSync += 1;
|
||||
console.error(`Failed to ${action} ${uid}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'archive' || action === 'delete') {
|
||||
await _animateEmailCardRemoval(uids);
|
||||
const removed = new Set(uids.map(uid => String(uid)));
|
||||
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
|
||||
}
|
||||
} finally {
|
||||
if (busySpinner) busySpinner.destroy();
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.classList.remove('email-bulk-loading');
|
||||
deleteBtn.innerHTML = originalDeleteHtml;
|
||||
}
|
||||
if (actionsBtn) actionsBtn.disabled = false;
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
if (selectAll) selectAll.disabled = false;
|
||||
if (countEl) countEl.textContent = originalCountText;
|
||||
}
|
||||
state._selectedUids.clear();
|
||||
state._selectMode = false;
|
||||
|
||||
@@ -78,10 +78,20 @@ function _captureRestoreHeight(modal, state) {
|
||||
if (!modal || !state) return;
|
||||
const content = modal.querySelector('.modal-content');
|
||||
if (!content) return;
|
||||
if (modal.id === 'email-lib-modal'
|
||||
&& (modal.classList.contains('modal-left-docked')
|
||||
|| modal.classList.contains('email-snap-left')
|
||||
|| document.body.classList.contains('email-doc-split-active'))) {
|
||||
delete state.restoreMinHeight;
|
||||
return;
|
||||
}
|
||||
const rect = content.getBoundingClientRect();
|
||||
if (!rect || rect.height < 120) return;
|
||||
const maxHeight = Math.max(180, window.innerHeight - 24);
|
||||
state.restoreMinHeight = `${Math.round(Math.min(rect.height, maxHeight))}px`;
|
||||
const minHeight = modal.id === 'email-lib-modal' && window.innerWidth > 768
|
||||
? Math.min(560, maxHeight)
|
||||
: 0;
|
||||
state.restoreMinHeight = `${Math.round(Math.max(minHeight, Math.min(rect.height, maxHeight)))}px`;
|
||||
}
|
||||
|
||||
function _applyRestoreHeight(modal, state) {
|
||||
@@ -90,7 +100,10 @@ function _applyRestoreHeight(modal, state) {
|
||||
if (!content) return;
|
||||
const maxHeight = Math.max(180, window.innerHeight - 24);
|
||||
const requested = parseInt(state.restoreMinHeight, 10);
|
||||
const height = Number.isFinite(requested) ? Math.min(requested, maxHeight) : null;
|
||||
const minHeight = modal.id === 'email-lib-modal' && window.innerWidth > 768
|
||||
? Math.min(560, maxHeight)
|
||||
: 0;
|
||||
const height = Number.isFinite(requested) ? Math.max(minHeight, Math.min(requested, maxHeight)) : null;
|
||||
if (height) content.style.minHeight = `${height}px`;
|
||||
}
|
||||
|
||||
@@ -380,7 +393,7 @@ function _renderDock() {
|
||||
chip.style.setProperty('position', 'fixed', 'important');
|
||||
chip.style.setProperty('left', `${pos.left}px`, 'important');
|
||||
chip.style.setProperty('top', `${pos.top}px`, 'important');
|
||||
chip.style.setProperty('z-index', '999', 'important');
|
||||
chip.style.setProperty('z-index', '10020', 'important');
|
||||
document.body.appendChild(chip);
|
||||
} else {
|
||||
dock.appendChild(chip);
|
||||
@@ -820,7 +833,7 @@ function _wireChipDrag(chip, dock) {
|
||||
// inline styles set via .style on some Safari versions.
|
||||
chip.style.setProperty('transition', 'none', 'important');
|
||||
chip.style.setProperty('transform', `translate(${tx}px, ${ty}px) scale(${inZone ? 1.12 : 1.05})`, 'important');
|
||||
chip.style.setProperty('z-index', '10000', 'important');
|
||||
chip.style.setProperty('z-index', '10030', 'important');
|
||||
chip.style.setProperty('position', 'fixed', 'important');
|
||||
chip.style.setProperty('left', `${chipStartLeft}px`, 'important');
|
||||
chip.style.setProperty('top', `${chipStartTop}px`, 'important');
|
||||
@@ -836,7 +849,7 @@ function _wireChipDrag(chip, dock) {
|
||||
if (dragMode === 'reorder') {
|
||||
chip.style.transition = 'none';
|
||||
chip.style.transform = `translate(${dx}px, ${dy}px) scale(1.05)`;
|
||||
chip.style.zIndex = '1000';
|
||||
chip.style.zIndex = '10030';
|
||||
|
||||
// Find sibling under cursor and swap
|
||||
const siblings = [...dock.querySelectorAll('.minimized-dock-chip:not(.dragging)')];
|
||||
@@ -1214,7 +1227,9 @@ export function minimize(id) {
|
||||
// If this window is edge-docked (right/left), SUSPEND the dock: release
|
||||
// the body push so the chat returns to full width while the window is
|
||||
// minimized, but keep the dock so restoring the chip snaps it back in.
|
||||
if (modal.classList.contains('modal-right-docked') || modal.classList.contains('modal-left-docked')) {
|
||||
if (modal.classList.contains('modal-right-docked')
|
||||
|| modal.classList.contains('modal-left-docked')
|
||||
|| modal.classList.contains('email-snap-left')) {
|
||||
try { suspendDock(modal); } catch (e) { console.warn('suspendDock on minimize failed', e); }
|
||||
}
|
||||
modal.classList.add('hidden');
|
||||
@@ -1453,6 +1468,24 @@ const _SWIPE_DOWN_MINIMIZES = new Set([
|
||||
// (per-email reader tabs) survive swipe-down too.
|
||||
const _SWIPE_DOWN_MINIMIZES_PREFIX = ['email-reader-'];
|
||||
|
||||
function _clearEmailSplitAfterMinimize() {
|
||||
document.body.classList.remove('email-doc-split-active', 'email-front');
|
||||
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) {
|
||||
[
|
||||
'position', 'left', 'right', 'top', 'bottom', 'width', 'max-width',
|
||||
'height', 'z-index', 'transform',
|
||||
].forEach(prop => docPane.style.removeProperty(prop));
|
||||
}
|
||||
const divider = document.getElementById('doc-divider');
|
||||
if (divider) divider.style.display = '';
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||
setTimeout(() => window.dispatchEvent(new Event('resize')), 80);
|
||||
}
|
||||
|
||||
// Re-route swipe-dismiss to minimize-rather-than-close — but only for the
|
||||
// allowlisted tools above. For every other modal, return early so the
|
||||
// default close handler runs and the modal goes away.
|
||||
@@ -1479,7 +1512,16 @@ window.addEventListener('modal-dismissed', (e) => {
|
||||
s.isMinimized = true;
|
||||
_setBadge(s.btnIds, true);
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) modal.classList.add('modal-minimized');
|
||||
if (modal) {
|
||||
const isEmailModal = id === 'email-lib-modal' || id.startsWith('email-reader-');
|
||||
if (modal.classList.contains('modal-right-docked')
|
||||
|| modal.classList.contains('modal-left-docked')
|
||||
|| modal.classList.contains('email-snap-left')) {
|
||||
try { suspendDock(modal); } catch (err) { console.warn('suspendDock on dismissed failed', err); }
|
||||
}
|
||||
if (isEmailModal) _clearEmailSplitAfterMinimize();
|
||||
modal.classList.add('modal-minimized');
|
||||
}
|
||||
_ensureDock();
|
||||
_renderDock();
|
||||
// Stop legacy listeners that reset internal `_open` state
|
||||
|
||||
@@ -522,6 +522,9 @@ export function clearRightDock(modal, cx, cy, dockClass) {
|
||||
if (!modal.classList.contains(dockClass)) return;
|
||||
modal.classList.remove(dockClass);
|
||||
clearDockSide(side, modal);
|
||||
if (side === 'left' && !_hasOtherDockedWindow('left', modal)) {
|
||||
_clearEmailDocSplitGeometry();
|
||||
}
|
||||
delete content._dockSide;
|
||||
_disconnectLeftDockObservers(content);
|
||||
const snap = content._preDockSnapshot;
|
||||
@@ -579,8 +582,10 @@ export function suspendDock(modal) {
|
||||
const nodes = _resolveDockNodes(modal);
|
||||
if (!nodes || !nodes.content) return null;
|
||||
const content = nodes.content;
|
||||
const hadEmailSnapLeft = modal.classList.contains('email-snap-left');
|
||||
const side = content._dockSide
|
||||
|| (modal.classList.contains('modal-left-docked') ? 'left'
|
||||
: modal.classList.contains('email-snap-left') ? 'left'
|
||||
: modal.classList.contains('modal-right-docked') ? 'right' : null);
|
||||
if (!side) return null;
|
||||
// Stop the close-watcher from tearing the dock fully down when `.hidden`
|
||||
@@ -592,6 +597,19 @@ export function suspendDock(modal) {
|
||||
}
|
||||
// Release the body push + restore the sidebar so the chat fills the width.
|
||||
clearDockSide(side, modal);
|
||||
if (side === 'left') {
|
||||
_disconnectLeftDockObservers(content);
|
||||
}
|
||||
if (hadEmailSnapLeft) {
|
||||
modal.classList.remove('email-snap-left');
|
||||
_clearEmailDocSplitGeometry();
|
||||
delete content._dockSide;
|
||||
delete content._dockSuspended;
|
||||
return null;
|
||||
}
|
||||
if (side === 'left' && !_hasOtherDockedWindow('left', modal)) {
|
||||
_clearEmailDocSplitGeometry();
|
||||
}
|
||||
if (content._preDockSnapshot?.collapsedSidebar && !_hasAnyOtherDockedWindow(modal)) {
|
||||
_expandSidebarFromRail();
|
||||
}
|
||||
|
||||
@@ -2305,7 +2305,7 @@ function _renderActivityEntry(entry) {
|
||||
const stale = !isQueued && (Date.now() - startMs) > 30 * 60 * 1000;
|
||||
const label = isQueued ? 'Queued' : stale ? 'Still running' : 'Running';
|
||||
const elapsedInit = isQueued ? '' : `<span class="task-log-running-elapsed" data-since="${startMs}">${_fmtElapsed(Date.now() - startMs)}</span>`;
|
||||
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue" style="border:0;background:transparent;box-shadow:none;margin-left:5px;padding:0;width:12px;height:12px;display:inline-flex;align-items:center;justify-content:center;font-size:10px;line-height:1;color:inherit;opacity:.8;"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style="display:block;"><polygon points="6 4 20 12 6 20 6 4"/></svg></button>` : '';
|
||||
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Start now</span></button>` : '';
|
||||
const stopBtn = entry.taskId ? `<button class="task-log-stop" type="button" title="Stop this task"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg></button>` : '';
|
||||
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${forceBtn}${stopBtn}</span>`;
|
||||
} else {
|
||||
|
||||
@@ -63,6 +63,7 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||
const onExitFullscreen = options.onExitFullscreen || null;
|
||||
const enableFullscreen = options.enableFullscreen !== false && !!onEnterFullscreen;
|
||||
const onDragEnd = options.onDragEnd || null;
|
||||
const onDragStart = options.onDragStart || null;
|
||||
const skipSelector = options.skipSelector || 'button, input, select';
|
||||
const mobileSkip = (typeof options.mobileSkip === 'number') ? options.mobileSkip : 768;
|
||||
const enableTouch = options.enableTouch !== false;
|
||||
@@ -147,7 +148,11 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||
|
||||
const _startDrag = (cx, cy) => {
|
||||
dragging = true;
|
||||
if (modal) modal.classList.add('modal-dragging');
|
||||
const rect = content.getBoundingClientRect();
|
||||
if (onDragStart) {
|
||||
try { onDragStart({ rect, cx, cy }); } catch (_) {}
|
||||
}
|
||||
startX = cx; startY = cy;
|
||||
startLeft = rect.left; startTop = rect.top;
|
||||
// Pin position so the drag follows the cursor instead of fighting a
|
||||
@@ -237,6 +242,7 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||
const _onEnd = (cx, cy) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
if (modal) modal.classList.remove('modal-dragging');
|
||||
_showSnapHint(false);
|
||||
// Top edge wins over side edges — fullscreen is the more common gesture.
|
||||
if (enableFullscreen && typeof cy === 'number' && cy <= SNAP_PX) {
|
||||
|
||||
338
static/style.css
338
static/style.css
@@ -832,7 +832,7 @@ body.bg-pattern-sparkles {
|
||||
display: flex; gap: 6px; flex-wrap: wrap;
|
||||
max-width: calc(100vw - 24px);
|
||||
padding: 4px;
|
||||
z-index: 999;
|
||||
z-index: 10020;
|
||||
pointer-events: none;
|
||||
}
|
||||
.minimized-dock-chip {
|
||||
@@ -907,7 +907,7 @@ body.bg-pattern-sparkles {
|
||||
color-mix(in srgb, #f0abfc 22%, var(--panel, var(--bg))));
|
||||
border-color: color-mix(in srgb, var(--accent, var(--red)) 72%, #fff 12%) !important;
|
||||
animation: chip-long-press-pulse 0.82s ease-in-out infinite;
|
||||
z-index: 10;
|
||||
z-index: 10030;
|
||||
}
|
||||
.minimized-dock-chip.chip-long-press::before {
|
||||
content: '';
|
||||
@@ -10107,6 +10107,24 @@ textarea.memory-add-input {
|
||||
#memory-modal .memory-bulk-bar {
|
||||
padding-right: 18px;
|
||||
}
|
||||
#email-lib-bulk-delete.email-bulk-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
opacity: 0.9;
|
||||
cursor: wait;
|
||||
}
|
||||
#email-lib-bulk-delete.email-bulk-loading .email-bulk-whirlpool {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
#email-lib-bulk-delete.email-bulk-loading .email-bulk-loading-label {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
/* Drafts bulk bar defaults to justify-content:flex-end (whole row hugs the
|
||||
right). Reset it so All + count sit on the left and only the action button
|
||||
is pushed right — matching every other bulk bar. */
|
||||
@@ -14503,10 +14521,10 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
overflow: hidden !important;
|
||||
z-index: 155 !important;
|
||||
}
|
||||
body.email-doc-split-active #email-lib-modal.email-snap-left .modal-content,
|
||||
body.email-doc-split-active #email-lib-modal.modal-left-docked .modal-content,
|
||||
body.email-doc-split-active .modal[id^="email-reader-"].email-snap-left .modal-content,
|
||||
body.email-doc-split-active .modal[id^="email-reader-"].modal-left-docked .modal-content {
|
||||
body.email-doc-split-active #email-lib-modal.email-snap-left:not(.modal-dragging) .modal-content,
|
||||
body.email-doc-split-active #email-lib-modal.modal-left-docked:not(.modal-dragging) .modal-content,
|
||||
body.email-doc-split-active .modal[id^="email-reader-"].email-snap-left:not(.modal-dragging) .modal-content,
|
||||
body.email-doc-split-active .modal[id^="email-reader-"].modal-left-docked:not(.modal-dragging) .modal-content {
|
||||
position: absolute !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
@@ -14821,6 +14839,11 @@ body.left-dock-active {
|
||||
#email-lib-modal .modal-content {
|
||||
transition: width 0.22s ease-out, height 0.22s ease-out;
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
body:not(.email-doc-split-active) #email-lib-modal:not(.email-lib-fullscreen):not(.modal-left-docked):not(.modal-right-docked) .modal-content {
|
||||
min-height: min(560px, 85vh);
|
||||
}
|
||||
}
|
||||
|
||||
/* Cookbook's cached-model list should scale with viewport height, not be capped at 400px */
|
||||
.hwfit-cached-list {
|
||||
@@ -17583,6 +17606,30 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
min-height: 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
#cookbook-modal .modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
#cookbook-modal .modal-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#cookbook-modal .cookbook-body {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
#cookbook-modal .cookbook-group {
|
||||
min-height: 0;
|
||||
}
|
||||
#cookbook-modal .cookbook-group > .admin-card {
|
||||
min-height: 0;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
#cookbook-modal .cookbook-section-body {
|
||||
min-height: 0;
|
||||
}
|
||||
.cookbook-body::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
@@ -19096,6 +19143,44 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
.cookbook-task-clear-label { display: none; }
|
||||
.cookbook-task-check-ico { display: none; }
|
||||
.cookbook-task-clear-ico { display: inline; }
|
||||
.cookbook-task[data-status="done"] .cookbook-task-check {
|
||||
color: var(--green, #50fa7b);
|
||||
}
|
||||
.cookbook-task[data-status="done"] .cookbook-task-check:hover {
|
||||
background: color-mix(in srgb, var(--green, #50fa7b) 16%, transparent);
|
||||
}
|
||||
.cookbook-task[data-status="done"] .cookbook-task-done-label {
|
||||
color: var(--green, #50fa7b);
|
||||
}
|
||||
.cookbook-task[data-status="done"] .cookbook-task-check-ico { display: inline; }
|
||||
.cookbook-task[data-status="done"] .cookbook-task-clear-ico { display: none; }
|
||||
.cookbook-task-start-now {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
position: relative;
|
||||
top: -4px;
|
||||
cursor: pointer;
|
||||
padding: 1px 6px 1px 4px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
text-transform: lowercase;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.cookbook-task-start-now svg {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
.cookbook-task-start-now:hover {
|
||||
background: color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
}
|
||||
/* "Serve" button on a finished download — green pill matching the "running" /
|
||||
finished badge (it sits next to the green FINISHED chip + check). */
|
||||
.cookbook-task-serve-btn {
|
||||
@@ -20252,6 +20337,68 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
.hwfit-toolbar .hwfit-usecase { min-width: 70px; flex-shrink: 0; }
|
||||
.hwfit-toolbar .hwfit-quant { min-width: 50px; flex-shrink: 0; }
|
||||
.hwfit-toolbar .hwfit-search { flex: 1; min-width: 80px; }
|
||||
.hwfit-help-chip {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid color-mix(in srgb, var(--fg) 22%, transparent);
|
||||
color: color-mix(in srgb, var(--fg) 55%, transparent);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
margin-left: -1px;
|
||||
}
|
||||
.hwfit-help-chip:hover {
|
||||
color: var(--fg);
|
||||
border-color: color-mix(in srgb, var(--fg) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
}
|
||||
.hwfit-help-chip-inline {
|
||||
margin-left: -2px;
|
||||
margin-right: 0;
|
||||
}
|
||||
.hwfit-ctx-control {
|
||||
height: 28px;
|
||||
min-width: 134px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--fg-muted);
|
||||
background: var(--bg);
|
||||
font-size: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hwfit-ctx-control span {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.hwfit-ctx-control input[type="range"] {
|
||||
width: 54px;
|
||||
min-width: 54px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
accent-color: var(--accent, var(--red));
|
||||
}
|
||||
.hwfit-ctx-control output {
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.hwfit-server-toggle { flex-shrink: 0; font-size: 10px !important; padding: 3px 8px !important; white-space: nowrap; }
|
||||
.hwfit-toolbar .hwfit-host { width: 110px; flex-shrink: 0; }
|
||||
.hwfit-env-row { gap: 6px; flex-wrap: wrap; }
|
||||
@@ -20447,7 +20594,7 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
.hwfit-c-ctx { width: 32px; }
|
||||
.hwfit-c-speed { width: 44px; }
|
||||
.hwfit-c-score { width: 40px; font-weight: 700; font-size: 11px; color: var(--fg); }
|
||||
.hwfit-c-mode { width: 48px; }
|
||||
.hwfit-c-mode { width: 72px; }
|
||||
.hwfit-moe {
|
||||
display: inline-block; padding: 0 4px; border-radius: 4px; margin-left: 4px;
|
||||
background: color-mix(in srgb, var(--red) 15%, transparent);
|
||||
@@ -20521,6 +20668,15 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
.hwfit-panel-actions {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
}
|
||||
.hwfit-panel-note {
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
color: var(--fg-muted);
|
||||
background: color-mix(in srgb, var(--yellow, #f1fa8c) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--yellow, #f1fa8c) 18%, var(--border));
|
||||
border-radius: 4px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
/* ── Saved presets ── */
|
||||
.hwfit-preset {
|
||||
@@ -21076,6 +21232,36 @@ body:not(.welcome-ready) #welcome-screen {
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.task-log-force-run {
|
||||
border: 0;
|
||||
background: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
box-shadow: none;
|
||||
color: inherit;
|
||||
opacity: .82;
|
||||
margin-left: 7px;
|
||||
padding: 1px 6px 1px 4px;
|
||||
min-height: 16px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
.task-log-force-run svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-log-force-run:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--green, #50fa7b) 16%, transparent);
|
||||
color: var(--green, #50fa7b);
|
||||
}
|
||||
.task-log-stop {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -26584,17 +26770,17 @@ button .spinner-whirlpool {
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
opacity: 0.15; color: var(--fg);
|
||||
}
|
||||
/* Hover preview: bright accent when un-checked so the user sees a check
|
||||
coming; dim+grey when already active so they can distinguish the
|
||||
"click to UN-check" target from the active state itself. */
|
||||
/* Hover preview: bright accent when unchecked so the user sees a check coming.
|
||||
Once active, keep the exact same color on hover so the done state does not
|
||||
visually flip while the pointer is still over it. */
|
||||
.email-card-done:not(.active):hover {
|
||||
opacity: 0.75 !important;
|
||||
color: var(--accent-primary, var(--red));
|
||||
}
|
||||
.email-card-done.active { opacity: 0.95; color: var(--accent-primary, var(--red)); }
|
||||
.email-card-done.active:hover {
|
||||
opacity: 0.35 !important;
|
||||
color: var(--fg) !important;
|
||||
opacity: 0.95 !important;
|
||||
color: var(--accent-primary, var(--red)) !important;
|
||||
}
|
||||
.email-card-done.just-checked {
|
||||
animation: check-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
@@ -26742,6 +26928,7 @@ button .spinner-whirlpool {
|
||||
}
|
||||
.recipient-chip {
|
||||
display: inline-flex; align-items: center;
|
||||
gap: 5px;
|
||||
padding: 1px 8px; font-size: 10px;
|
||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
@@ -26754,6 +26941,35 @@ button .spinner-whirlpool {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.recipient-chip-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.recipient-chip-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.recipient-chip-copy:hover,
|
||||
.recipient-chip-copy.copied {
|
||||
opacity: 1;
|
||||
color: var(--accent-primary, var(--red));
|
||||
}
|
||||
.recipient-chip-copy[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.recipient-chip:hover {
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--red)) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 40%, transparent);
|
||||
@@ -28239,6 +28455,55 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
display: flex; flex-direction: column; gap: 6px; padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border); background: var(--bg); flex-shrink: 0;
|
||||
}
|
||||
.doc-email-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
.doc-email-collapse-btn {
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 2px 4px 3px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
opacity: 0.72;
|
||||
text-align: left;
|
||||
}
|
||||
.doc-email-collapse-btn:hover { opacity: 1; color: var(--accent, var(--red)); }
|
||||
.doc-email-collapse-btn svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.65;
|
||||
transition: transform 0.14s ease;
|
||||
}
|
||||
.doc-email-collapse-summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.74;
|
||||
}
|
||||
.doc-email-header:not(.doc-email-header-collapsed) .doc-email-collapse-summary {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.doc-email-header.doc-email-header-collapsed {
|
||||
gap: 0;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.doc-email-header.doc-email-header-collapsed .doc-email-fields {
|
||||
display: none;
|
||||
}
|
||||
.doc-email-header.doc-email-header-collapsed .doc-email-collapse-btn svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.email-field { display: flex; align-items: center; gap: 8px; position: relative; }
|
||||
.email-field label { font-size: 11px; font-weight: 600; color: var(--fg); opacity: 0.5; min-width: 50px; text-align: right; flex-shrink: 0; }
|
||||
.email-field input {
|
||||
@@ -28267,6 +28532,11 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
position: absolute; right: 6px; top: calc(50% + 4px); transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.email-field .email-cc-toggle {
|
||||
top: calc(50% + 4px);
|
||||
}
|
||||
}
|
||||
.email-field input { padding-right: 60px; }
|
||||
.email-field #doc-email-cc, .email-field #doc-email-bcc, .email-field #doc-email-subject { padding-right: 8px; }
|
||||
|
||||
@@ -28441,17 +28711,39 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
gap: 0;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.doc-email-collapse-btn {
|
||||
background: inherit;
|
||||
}
|
||||
/* Mobile: keep the pill but ensure a comfortable touch target. */
|
||||
.email-attachment-open {
|
||||
height: 26px; padding: 0 10px;
|
||||
min-height: 26px !important;
|
||||
}
|
||||
.email-attachments,
|
||||
.email-compose-atts {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding-left: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.email-attachments::-webkit-scrollbar,
|
||||
.email-compose-atts::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Attachment chip body — modest minimum height so the open icon sits
|
||||
neatly without dominating. */
|
||||
.email-attachment-chip {
|
||||
.email-attachment-chip,
|
||||
.email-compose-chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 8px !important;
|
||||
min-height: 36px !important;
|
||||
}
|
||||
.email-compose-chip .compose-chip-name {
|
||||
max-width: 190px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compose attachment chips (when sending new email) */
|
||||
@@ -28478,7 +28770,25 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
opacity: 0.4; font-size: 11px; cursor: pointer;
|
||||
padding: 4px 8px; font-family: inherit;
|
||||
}
|
||||
.email-cc-toggle:hover { opacity: 1; color: var(--accent, #4a9eff); }
|
||||
.email-cc-toggle:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent, #4a9eff);
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.doc-email-collapse-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
#doc-email-header #doc-email-collapse-btn.doc-email-collapse-btn {
|
||||
display: none !important;
|
||||
}
|
||||
#doc-email-header.doc-email-header-collapsed .doc-email-fields {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
|
||||
.email-autocomplete {
|
||||
position: absolute; top: 100%; left: 58px; right: 0; z-index: 1000;
|
||||
|
||||
Reference in New Issue
Block a user