Polish email and cookbook flows

This commit is contained in:
pewdiepie-archdaemon
2026-06-02 22:38:55 +09:00
parent 15a2662119
commit ff93a6c63b
22 changed files with 1492 additions and 218 deletions

View File

@@ -37,6 +37,12 @@ the codebase, you are probably right to stay away.
fetched pages, and memories should be treated as untrusted data. Keep testing
whether models follow malicious instructions from those surfaces.
- Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes.
- Email performance audit. Fetching, searching, opening, deleting, and sending
email can feel slow, especially over IMAP/SMTP providers with high latency.
Need someone who knows mail performance to profile the current flow, identify
whether the bottleneck is IMAP folder select/fetch, cache invalidation,
attachment/body loading, SMTP handshakes, or frontend refresh behavior, then
propose safer caching/prefetch/batching without breaking multi-account state.
- Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek.
## Refactor Targets

View File

@@ -546,12 +546,18 @@ def setup_cookbook_routes() -> APIRouter:
)
# Ensure pip-user scripts (e.g. hf CLI installed via --user) are on PATH
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
# Install hf CLI + hf_transfer best-effort so future runs get the fast path.
# Install hf CLI + optional hf_transfer best-effort. Retries disable
# hf_transfer because the Rust parallel path is fast but has been
# flaky near the end of very large multi-file downloads.
# Use --break-system-packages on PEP-668 systems (Arch, newer Debian) so it doesn't bail.
runner_lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', python_cmd='pip', upgrade=True)}")
runner_lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer', python_cmd='pip')}")
runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1")
runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8")
if req.disable_hf_transfer:
runner_lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0")
runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4")
else:
runner_lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer', python_cmd='pip')}")
runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1")
runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8")
# Surface whether the HF token actually reached THIS server, so a gated
# download's "not authorized" failure can be told apart from a missing
# token (the token is masked — we only print applied / not-set).
@@ -562,13 +568,17 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append(f' {hf_cmd} < /dev/null')
runner_lines.append('elif python3 -c "import huggingface_hub" 2>/dev/null; then')
runner_lines.append(' echo "hf CLI not found, using Python huggingface_hub..."')
runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers=8)"')
runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={4 if req.disable_hf_transfer else 8})"')
runner_lines.append('else')
runner_lines.append(' echo "Installing huggingface-hub and dependencies..."')
runner_lines.append(' pip install --no-deps -q huggingface-hub 2>/dev/null')
runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests hf_transfer 2>/dev/null')
runner_lines.append(" python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1")
runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers=8)"')
if req.disable_hf_transfer:
runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests 2>/dev/null')
runner_lines.append(' export HF_HUB_ENABLE_HF_TRANSFER=0')
else:
runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests hf_transfer 2>/dev/null')
runner_lines.append(" python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1")
runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={4 if req.disable_hf_transfer else 8})"')
runner_lines.append('fi')
runner_lines.append('if [ $? -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $?)"; fi')
runner_lines.append(f"rm -f {remote_runner}")
@@ -994,16 +1004,20 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append(f'ODYSSEUS_OLLAMA_HOST={_bash_squote(_ollama_host)}')
runner_lines.append(f'ODYSSEUS_OLLAMA_PORT="{_ollama_port}"')
runner_lines.append('ODYSSEUS_OLLAMA_URL=""')
runner_lines.append('for _ody_ollama_port in "$ODYSSEUS_OLLAMA_PORT" 11434; do')
runner_lines.append(' [ -z "$_ody_ollama_port" ] && continue')
runner_lines.append(' for _ody_ollama_host in 127.0.0.1 localhost host.docker.internal; do')
runner_lines.append(' _ody_ollama_url="http://${_ody_ollama_host}:${_ody_ollama_port}"')
runner_lines.append(' if curl -sf "$_ody_ollama_url/api/tags" >/dev/null 2>&1; then')
runner_lines.append(' ODYSSEUS_OLLAMA_URL="$_ody_ollama_url"')
runner_lines.append(' ODYSSEUS_OLLAMA_PORT="$_ody_ollama_port"')
runner_lines.append(' break 2')
runner_lines.append(' fi')
runner_lines.append('for _ody_ollama_try in $(seq 1 20); do')
runner_lines.append(' for _ody_ollama_port in "$ODYSSEUS_OLLAMA_PORT" 11434; do')
runner_lines.append(' [ -z "$_ody_ollama_port" ] && continue')
runner_lines.append(' for _ody_ollama_host in 127.0.0.1 localhost host.docker.internal; do')
runner_lines.append(' _ody_ollama_url="http://${_ody_ollama_host}:${_ody_ollama_port}"')
runner_lines.append(' if curl -sf "$_ody_ollama_url/api/tags" >/dev/null 2>&1; then')
runner_lines.append(' ODYSSEUS_OLLAMA_URL="$_ody_ollama_url"')
runner_lines.append(' ODYSSEUS_OLLAMA_PORT="$_ody_ollama_port"')
runner_lines.append(' break 3')
runner_lines.append(' fi')
runner_lines.append(' done')
runner_lines.append(' done')
runner_lines.append(' [ "$_ody_ollama_try" -eq 1 ] && echo "[odysseus] Waiting for an existing Ollama API on ports ${ODYSSEUS_OLLAMA_PORT}/11434..."')
runner_lines.append(' sleep 1')
runner_lines.append('done')
runner_lines.append('if [ -n "$ODYSSEUS_OLLAMA_URL" ]; then')
runner_lines.append(' if [ "$ODYSSEUS_OLLAMA_PORT" != "' + _ollama_port + '" ]; then')
@@ -1820,6 +1834,43 @@ def setup_cookbook_routes() -> APIRouter:
def _cookbook_tasks_status_sync():
import subprocess
def _download_cache_complete(repo_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
"""Best-effort check for a completed HF cache entry.
tmux output can stop at a stale progress line if the pane/session
disappears before Cookbook captures the final DOWNLOAD_OK marker.
In that case, trust the cache shape: a snapshot directory with files
and no *.incomplete blobs means HuggingFace finished materializing the
model.
"""
if not repo_id or "/" not in repo_id:
return False
py = (
"import os,sys;"
"repo=sys.argv[1];"
"base=os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub');"
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
"snap=os.path.join(d,'snapshots');"
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
"inc=False;"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"sys.exit(0 if ok and not inc else 1)"
)
cmd = ["python3", "-c", py, repo_id]
try:
if remote_host:
ssh_base = ["ssh"]
if ssh_port and ssh_port != "22":
ssh_base.extend(["-p", str(ssh_port)])
shell_cmd = " ".join(shlex.quote(x) for x in cmd)
proc = subprocess.run(ssh_base + [remote_host, shell_cmd], timeout=12, capture_output=True)
else:
proc = subprocess.run(cmd, timeout=12, capture_output=True)
return proc.returncode == 0
except Exception:
return False
# Load saved tasks from cookbook state
tasks = []
if _cookbook_state_path.exists():
@@ -1996,7 +2047,14 @@ def setup_cookbook_routes() -> APIRouter:
status = "running"
else:
# Session is dead — check if it completed or crashed
status = "stopped"
if task_type == "download" and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or "")):
status = "completed"
if not progress_text:
progress_text = "Download complete"
if not full_snapshot:
full_snapshot = "DOWNLOAD_OK"
else:
status = "stopped"
# Parse structured phase info — single source of truth for the UI
phase_info = _parse_serve_phase(full_snapshot, task_type) if (task_type == "serve" and status == "running" and full_snapshot) else {}

View File

@@ -457,7 +457,7 @@ def setup_email_routes():
_IMAP_POOL = {} # account_id → (conn, last_used_at)
_IMAP_IDLE_MAX = 60.0
_WARMING_READS = set()
_WARM_READ_LIMIT = 3
_WARM_READ_LIMIT = 1
_WARM_MAX_BYTES = 128 * 1024
_WARM_RECENT_SECONDS = 7 * 24 * 60 * 60
_pool_lock = _threading.Lock()
@@ -1046,7 +1046,7 @@ def setup_email_routes():
# Escape backslash and quote for the IMAP-SEARCH quoted-string.
q_escaped = q.replace('\\', '\\\\').replace('"', '\\"')
search_cmd = f'(OR FROM "{q_escaped}" TEXT "{q_escaped}")'
search_cmd = f'(OR OR FROM "{q_escaped}" SUBJECT "{q_escaped}" TEXT "{q_escaped}")'
status, data = _imap_uid_search(conn, search_cmd)
if status != "OK" or not data[0]:

View File

@@ -91,7 +91,7 @@ def setup_hwfit_routes():
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
@router.get("/models")
def get_models(use_case: str = "", sort: str = "score", limit: int = 50, search: str = "", host: str = "", quant: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False):
def get_models(use_case: str = "", sort: str = "score", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False):
"""Rank LLM models against detected hardware and return scored results.
gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the
active group). gpu_group: index into system.gpu_groups (the homogeneous
@@ -172,7 +172,14 @@ def setup_hwfit_routes():
# gpu_only stays off here so the default view still surfaces offload.
_apply_group(grp, grp["count"])
results = rank_models(system, use_case=use_case or None, limit=limit, search=search or None, sort=sort, quant=quant or None)
try:
target_context = int(ctx) if ctx else None
except ValueError:
target_context = None
if target_context is not None:
target_context = max(1024, min(target_context, 1000000))
results = rank_models(system, use_case=use_case or None, limit=limit, search=search or None, sort=sort, quant=quant or None, target_context=target_context)
return {"system": system, "models": results}
@router.get("/profiles")

View File

@@ -4375,7 +4375,14 @@
"hf_downloads": 51135,
"hf_likes": 2,
"release_date": "2025-09-23",
"_discovered": true
"_discovered": true,
"gguf_sources": [
{
"repo": "typhoon-ai/typhoon2.5-qwen3-4b-gguf",
"file": "typhoon2.5-qwen3-4b-q4_k_m.gguf",
"quant": "Q4_K_M"
}
]
},
{
"name": "JunHowie/Qwen3-4B-Instruct-2507-GPTQ-Int4",
@@ -8994,7 +9001,14 @@
"num_experts": 128,
"active_experts": 8,
"active_parameters": 3339450907,
"_discovered": true
"_discovered": true,
"gguf_sources": [
{
"repo": "typhoon-ai/typhoon2.5-qwen3-30b-a3b-gguf",
"file": "typhoon2.5-qwen3-30b-a3b-q4_k_m.gguf",
"quant": "Q4_K_M"
}
]
},
{
"name": "QuantTrio/Qwen3-Coder-30B-A3B-Instruct-AWQ",

View File

@@ -175,8 +175,15 @@ def _quality_score(model, quant, use_case):
model_uc = infer_use_case(model)
if model_uc == "coding" and use_case == "coding":
base += 6
elif model_uc == "coding" and use_case in ("general", "chat"):
# Coder-specialized models are still useful generally, but they should
# not dominate the default scan. If the user wants code, the Coding
# filter gives them the boost above.
base -= 10
if model_uc == "reasoning" and use_case == "reasoning" and pb >= 13:
base += 5
elif model_uc == "reasoning" and use_case == "chat":
base -= 4
if model_uc == "multimodal" and use_case == "multimodal":
base += 6
@@ -262,7 +269,30 @@ def _quant_bits(q):
return 0
def analyze_model(model, system, target_quant=None, scoring_use_case=None):
def _native_quant(model):
native_quant = model.get("quantization", "Q4_K_M")
name = (model.get("name") or "").lower()
fmt = (model.get("format") or "").lower()
text = f"{name} {fmt}"
if "nvfp4" in text:
return "NVFP4"
if re.search(r"(^|[-_/])fp8($|[-_/\s])", text):
return "FP8"
if "gptq" in text:
m = re.search(r"(?:gptq|int|w)(?:[-_]?)(\d{1,2})(?:bit)?", text)
return f"GPTQ-{m.group(1)}bit" if m else "GPTQ"
if "awq" in text:
m = re.search(r"(?:awq|int|w)(?:[-_]?)(\d{1,2})(?:bit)?", text)
return f"AWQ-{m.group(1)}bit" if m else "AWQ"
if "mlx" in text:
m = re.search(r"mlx[-_]?(\d{1,2})bit", text)
return f"mlx-{m.group(1)}bit" if m else native_quant
if not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text):
return "INT8"
return native_quant
def analyze_model(model, system, target_quant=None, scoring_use_case=None, target_context=None):
pb = params_b(model)
if pb <= 0:
return None
@@ -282,11 +312,14 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None):
gpu_only = bool(system.get("gpu_only")) and has_gpu and gpu_vram > 0
eff_ram = 0 if gpu_only else available_ram
is_moe = model.get("is_moe", False)
ctx = model.get("context_length", 4096) or 4096
model_ctx = model.get("context_length", 4096) or 4096
try:
target_context = int(target_context or 0)
except (TypeError, ValueError):
target_context = 0
ctx = min(model_ctx, target_context) if target_context > 0 else model_ctx
native_quant = model.get("quantization", "Q4_K_M")
if "nvfp4" in (model.get("name") or "").lower():
native_quant = "NVFP4"
native_quant = _native_quant(model)
preq = is_prequantized(model)
# GGUF models can't be sharded across GPUs — use single GPU VRAM
@@ -355,7 +388,8 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None):
"score": 0,
"scores": {"quality": 0, "speed": 0, "fit": 0, "context": 0},
"gguf_sources": model.get("gguf_sources", []),
"context_length": model.get("context_length", 4096),
"context_length": model_ctx,
"target_context": target_context or None,
}
run_mode, quant, fit_ctx, required_gb = result
@@ -413,8 +447,9 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None):
"context": round(c_score, 1),
},
"gguf_sources": model.get("gguf_sources", []),
"context_length": model.get("context_length", 4096),
"context_length": model_ctx,
"release_date": model.get("release_date", ""),
"target_context": target_context or None,
}
@@ -431,7 +466,7 @@ SORT_KEYS = {
}
def rank_models(system, use_case=None, limit=50, search=None, sort="score", quant=None):
def rank_models(system, use_case=None, limit=50, search=None, sort="score", quant=None, target_context=None):
"""Rank all models against detected hardware. Returns sorted list of fit results."""
models = get_models()
results = []
@@ -495,9 +530,7 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
consumer_amd = system_backend == "rocm" and gpu_family == "rdna"
for m in models:
native_q = m.get("quantization", "")
if "nvfp4" in (m.get("name") or "").lower():
native_q = "NVFP4"
native_q = _native_quant(m)
# MLX needs the mlx_lm runtime, which Odysseus does not generate serve
# commands for. Hide it on every backend, including Metal.
@@ -548,7 +581,7 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
if search.lower() not in name and search.lower() not in provider:
continue
result = analyze_model(m, system, target_quant=quant, scoring_use_case=(use_case or "general"))
result = analyze_model(m, system, target_quant=quant, scoring_use_case=(use_case or "general"), target_context=target_context)
if result is None:
continue

View File

@@ -101,7 +101,16 @@ def _normalize_model_entry(model):
def is_prequantized(model):
q = model.get("quantization", "")
return any(q.startswith(p) for p in PREQUANTIZED_PREFIXES)
name = (model.get("name") or "").lower()
fmt = (model.get("format") or "").lower()
text = f"{name} {fmt}"
return (
"nvfp4" in text
or re.search(r"(^|[-_/])fp8($|[-_/\s])", text) is not None
or (not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text) is not None)
or any(x in text for x in ("awq", "gptq", "mlx"))
or any(q.startswith(p) for p in PREQUANTIZED_PREFIXES)
)
def params_b(model):

View File

@@ -115,6 +115,7 @@ _API_AGENT_RULES = """\
- Keep answers concise unless the user asks for depth.
- For long code or content, use document tools instead of pasting large blocks into chat.
- Editing an existing document: ALWAYS use `edit_document` with find/replace. Only use `update_document` for genuine full rewrites (>50% changed) — do NOT echo the entire file back for small edits.
- If the active editor document is an email draft/compose window, treat that open email as the target for "write this", "write the email", "reply with...", "make it say...", "draft this", and similar requests. Do NOT create another document, search/list/manage documents, or open a different reply unless the user explicitly asks. Edit the open email draft with `edit_document` or `update_document`; preserve To/Cc/Bcc/Subject/In-Reply-To/References/X-* header lines unless the user asks to change them.
- "Give suggestions / feedback / review / how can I improve this / what would make it better" about the OPEN document → call `suggest_document`, do NOT write a prose list of ideas in chat. It creates inline accept/reject bubbles on the doc. Give concrete `find`/`replace`/`reason` items. To suggest an ADDITION (e.g. "add a bow to the SVG", a new section), set `find` to a short existing anchor snippet and `replace` to that same snippet PLUS the new content. Only answer in prose when no document is open, or the request is purely conceptual with no concrete change to propose.
- BIAS TOWARD ACTION on edit requests. If the user says "edit out X", "remove the Y paragraph", "change Z" — call the edit tool with your best interpretation. Don't ask for clarification on minor ambiguity. The user can undo.
- AFTER A TOOL SUCCEEDS, do not second-guess. A success response means it worked. Reply in ONE short sentence confirming what was done. No verification thinking, no re-analyzing — move on.
@@ -642,6 +643,7 @@ def _build_system_prompt(
f'ACTIVE EMAIL DRAFT (open in editor — the user is looking at this right now)\n'
f'Title: "{active_document.title}"\n'
f'```\n{_doc_raw}\n```\n\n'
f'This is the current email compose window, not a normal document library item. If the user says "write", "draft", "reply", "make it say", or "write the email" without naming another target, edit THIS email draft.\n\n'
f'When the user asks you to write, reply to, or improve this email:\n'
f'1. Use `update_document` to replace the ENTIRE content — keep all the header lines (To, Subject, In-Reply-To, References, X-Source-UID, X-Source-Folder, X-Attachments) and the `---` separator EXACTLY as they are.\n'
f'2. Replace ONLY the body text (the part after `---`). If there is a quoted original email (lines starting with `>`), keep that quoted block unchanged BELOW your new reply.\n'
@@ -792,7 +794,7 @@ def _build_system_prompt(
# When creating email documents, instruct the AI on the format
if relevant_tools and (_EMAIL_TOOL_HINTS & set(relevant_tools)):
agent_prompt += (
'\n\n📧 EMAIL DOCUMENT FORMAT: When drafting email replies, use create_document with language="email". '
'\n\n📧 EMAIL DOCUMENT FORMAT: If no email draft is already open and you need to create an email draft, use create_document with language="email". '
'The content format is:\n'
'To: recipient@example.com\n'
'Subject: Re: Original subject\n'
@@ -800,8 +802,8 @@ def _build_system_prompt(
'References: <original-message-id>\n'
'---\n'
'Body text here...\n\n'
'The user can then edit and click Send or Draft in the editor. For an already-open email draft, '
'edit the current document instead of creating another one.'
'The user can then edit and click Send or Draft in the editor. If an email draft is already open, '
'that open draft is the target: use update_document/edit_document on it instead of creating another document.'
)
# Inject relevant skills based on the user's last message. The

View File

@@ -603,44 +603,98 @@ def _sanitize_llm_messages(messages: List[Dict]) -> List[Dict]:
elif "content" in item:
cleaned.append(item)
# Merge consecutive user messages to satisfy strict role alternation requirements.
merged = []
for item in cleaned:
# Repair tool-call adjacency before sending to any OpenAI-compatible
# provider. Trimming/compaction/retries can leave `role:"tool"` messages
# without their immediately-preceding assistant `tool_calls` parent, which
# DeepSeek rejects with:
# "Messages with role 'tool' must be a response to a preceding message with
# 'tool_calls'". Also strip unanswered assistant tool_calls; some providers
# reject those as incomplete conversations.
repaired: List[Dict] = []
i = 0
while i < len(cleaned):
msg = cleaned[i]
role = msg.get("role")
if role == "tool":
# Orphan tool result. There is no valid assistant tool_calls parent
# immediately before this batch, so it cannot be sent.
logger.debug("Dropping orphan tool message before provider request")
i += 1
continue
tool_calls = msg.get("tool_calls") if role == "assistant" else None
if not tool_calls:
repaired.append(msg)
i += 1
continue
call_ids = [
str(tc.get("id"))
for tc in tool_calls
if isinstance(tc, dict) and tc.get("id")
]
expected = set(call_ids)
answered_ids = []
tool_batch = []
j = i + 1
while j < len(cleaned) and cleaned[j].get("role") == "tool":
tid = str(cleaned[j].get("tool_call_id") or "")
if tid in expected and tid not in answered_ids:
answered_ids.append(tid)
tool_batch.append(cleaned[j])
else:
logger.debug("Dropping unmatched/duplicate tool message before provider request")
j += 1
if not tool_batch:
plain = {k: v for k, v in msg.items() if k != "tool_calls"}
if (plain.get("content") or "").strip():
repaired.append(plain)
else:
logger.debug("Dropping unanswered assistant tool_calls before provider request")
i = j
continue
answered = set(answered_ids)
pruned_calls = [
tc for tc in tool_calls
if isinstance(tc, dict) and str(tc.get("id")) in answered
]
fixed = dict(msg)
fixed["tool_calls"] = pruned_calls
if "content" not in fixed:
fixed["content"] = None
repaired.append(fixed)
repaired.extend(tool_batch)
if len(pruned_calls) != len(tool_calls):
logger.debug("Pruned unanswered assistant tool_calls before provider request")
i = j
# Merge consecutive user messages to satisfy strict role alternation
# requirements after invalid tool-call fragments have been removed.
merged: List[Dict] = []
for item in repaired:
if not merged:
merged.append(item)
continue
last = merged[-1]
if last["role"] == "user" and item["role"] == "user":
if last.get("role") == "user" and item.get("role") == "user":
last_copy = dict(last)
# Content:
last_content = last_copy.get("content")
item_content = item.get("content")
# Convert contents to string if they exist, or keep None/empty
last_str = str(last_content) if last_content is not None else ""
item_str = str(item_content) if item_content is not None else ""
if last_str and item_str:
new_content = f"{last_str}\n\n{item_str}"
elif last_str:
new_content = last_str
else:
new_content = item_str if item_str else None
if new_content is not None:
last_str = str(last_copy.get("content")) if last_copy.get("content") is not None else ""
item_str = str(item.get("content")) if item.get("content") is not None else ""
new_content = "\n\n".join(part for part in (last_str, item_str) if part)
if new_content:
last_copy["content"] = new_content
elif "content" in last_copy:
del last_copy["content"]
else:
last_copy.pop("content", None)
merged[-1] = last_copy
else:
merged.append(item)
return merged
def _normalize_anthropic_url(url: str) -> str:
"""Ensure Anthropic URL points to /v1/messages."""
url = url.rstrip("/")

View File

@@ -65,7 +65,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.",
"read_file": "Read a file from disk and return its contents. View source code, config files, logs.",
"write_file": "Write content to a file on disk. Create new files, save output, update configs.",
"create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines. Specify title, language, and content.",
"create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines, unless an already-open document/email draft is the obvious target. If an email compose draft is open, edit that draft instead of creating another document.",
"edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.",
"update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.",
"suggest_document": "Suggest changes to the active document with explanations. For code review, proofreading, feedback requests.",

View File

@@ -111,7 +111,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "create_document",
"description": "Create a new document in the editor panel. ALWAYS use this when the user asks to write, create, build, or generate code, scripts, programs, games, apps, or any substantial content (>15 lines). NEVER put large code blocks directly in chat — use this tool instead.",
"description": "Create a new document in the editor panel. Use this when the user asks to write, create, build, or generate code, scripts, programs, games, apps, or any substantial content (>15 lines) AND there is no already-open document/email draft that the request refers to. If an email compose draft is open, edit that draft instead of creating another document. NEVER put large code blocks directly in chat — use this tool instead.",
"parameters": {
"type": "object",
"properties": {

View File

@@ -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) {

View File

@@ -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;">';

View File

@@ -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) {

View File

@@ -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">&#8942;</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;

View File

@@ -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

View File

@@ -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)} &lt;${_esc(data.from_address)}&gt;" 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)} &lt;${_esc(data.from_address)}&gt;" 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)} &lt;${_esc(data.from_address)}&gt;" 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)} &lt;${_esc(data.from_address)}&gt;</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;

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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;