From 5ed9b74cd0c68669f352dea429a0015fdea6248c Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Mon, 1 Jun 2026 20:56:11 +0900 Subject: [PATCH] Polish email tasks and window controls --- routes/email_helpers.py | 29 +++++-- routes/email_pollers.py | 118 +++++++++++++++++++------ routes/email_routes.py | 62 +++++++++----- routes/task_routes.py | 90 ++++++++++++++++++++ src/builtin_actions.py | 7 +- src/task_scheduler.py | 89 ++++++++++++++++++- static/index.html | 15 +++- static/js/document.js | 175 +++++++++++++++++++++++++++++++------- static/js/emailInbox.js | 127 +++++++++++++++++---------- static/js/emailLibrary.js | 119 +++++++++++++++++--------- static/js/settings.js | 14 +++ static/js/tasks.js | 125 +++++++++++++++++++++++---- static/js/ui.js | 83 +++++++++++++++++- static/style.css | 69 +++++++++++++-- 14 files changed, 919 insertions(+), 203 deletions(-) diff --git a/routes/email_helpers.py b/routes/email_helpers.py index 0315f06..27d7338 100644 --- a/routes/email_helpers.py +++ b/routes/email_helpers.py @@ -15,6 +15,7 @@ and `email_pollers.py` (the background loops): import os import imaplib import smtplib +import ssl import email as email_mod import email.header import email.utils @@ -50,17 +51,29 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message port = int(cfg.get("smtp_port") or 465) user = cfg.get("smtp_user") or "" password = cfg.get("smtp_password") or "" - if port == 587: - with smtplib.SMTP(host, port, timeout=timeout) as smtp: + def _send_starttls(starttls_port: int = 587) -> None: + with smtplib.SMTP(host, starttls_port, timeout=timeout) as smtp: smtp.starttls() if user and password: smtp.login(user, password) smtp.sendmail(from_addr, recipients, message) + + if port == 587: + _send_starttls(587) return - with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp: - if user and password: - smtp.login(user, password) - smtp.sendmail(from_addr, recipients, message) + + try: + with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp: + if user and password: + smtp.login(user, password) + smtp.sendmail(from_addr, recipients, message) + return + except (TimeoutError, ssl.SSLError) as e: + if port == 465: + logger.warning("SMTP implicit TLS on %s:465 failed (%s); retrying STARTTLS on 587", host, e) + _send_starttls(587) + return + raise def _strip_think(text: str) -> str: @@ -82,8 +95,8 @@ def _strip_think(text: str) -> str: import re as _re_reply # Accept REPLY / SUMMARY / OUTPUT as the opening fence so the same extractor # serves replies and summaries (any fenced final-output block). -_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>>", _re_reply.I) -_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>>", _re_reply.I) +_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I) +_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I) def _extract_reply(text: str) -> str: diff --git a/routes/email_pollers.py b/routes/email_pollers.py index ac21d52..7c9c3a0 100644 --- a/routes/email_pollers.py +++ b/routes/email_pollers.py @@ -23,6 +23,7 @@ import json import re import html import logging +import inspect from datetime import datetime from email.mime.text import MIMEText @@ -46,10 +47,22 @@ logger = logging.getLogger(__name__) # ── Routes ── +async def _emit_progress(progress_cb, message: str): + if not progress_cb: + return + try: + res = progress_cb(message) + if inspect.isawaitable(res): + await res + except Exception: + logger.debug("Email task progress callback failed", exc_info=True) + + async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = True, do_tag: bool = False, do_spam: bool = False, do_calendar: bool = False, - days_back: int = 1) -> str: + days_back: int = 1, + progress_cb=None) -> str: """One iteration of the email scan. Temporarily flips settings flags so the existing background-loop logic runs exactly once for the requested ops.""" settings = _load_settings() @@ -63,7 +76,7 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru settings["email_auto_calendar"] = bool(do_calendar) _save_settings(settings) try: - return await _auto_summarize_pass(days_back=days_back) + return await _auto_summarize_pass(days_back=days_back, progress_cb=progress_cb) finally: s2 = _load_settings() for k, v in prev.items(): @@ -71,7 +84,7 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru _save_settings(s2) -async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None) -> str: +async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, progress_cb=None) -> str: """Single pass of the auto-summarize/reply scan. When account_id is None, iterates over every enabled account in @@ -98,20 +111,21 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None names = {} if len(ids) <= 1: # Single-account (or zero rows — fallback to legacy settings.json lookup) - return await _auto_summarize_pass_single(days_back=days_back, account_id=(ids[0] if ids else None)) + return await _auto_summarize_pass_single(days_back=days_back, account_id=(ids[0] if ids else None), progress_cb=progress_cb) outs = [] - for aid in ids: + for idx, aid in enumerate(ids, start=1): try: - result = await _auto_summarize_pass_single(days_back=days_back, account_id=aid) + await _emit_progress(progress_cb, f"{names.get(aid, aid[:8])}: starting ({idx}/{len(ids)})") + result = await _auto_summarize_pass_single(days_back=days_back, account_id=aid, progress_cb=progress_cb) outs.append(f"[{names.get(aid, aid[:8])}] {result}") except Exception as e: logger.warning(f"auto-summarize pass failed for account {aid}: {e}") outs.append(f"[{names.get(aid, aid[:8])}] error: {e}") return "\n".join(outs) - return await _auto_summarize_pass_single(days_back=days_back, account_id=account_id) + return await _auto_summarize_pass_single(days_back=days_back, account_id=account_id, progress_cb=progress_cb) -async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None) -> str: +async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, progress_cb=None) -> str: """Single pass of the auto-summarize/reply scan for ONE account. Reads current settings flags.""" import asyncio @@ -130,11 +144,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None return "Nothing to do" try: + await _emit_progress(progress_cb, "Connecting to mail…") conn = _imap_connect(account_id) from datetime import timedelta as _td since = (datetime.utcnow() - _td(days=max(1, days_back))).strftime("%d-%b-%Y") - # uid_list now carries (folder, uid) tuples — for calendar extraction we - # also scan Sent so the LLM sees confirmation/cancellation replies the user wrote. + # uid_list carries real IMAP UIDs, matching the email UI/read routes. + # Using sequence numbers here made background-cached replies miss when + # the user clicked the same visible message in the UI. uid_list = [] folders_to_scan = ["INBOX"] if auto_cal: @@ -149,17 +165,33 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None for folder in folders_to_scan: try: conn.select(_q(folder), readonly=True) - status, data = conn.search(None, f'(SINCE {since})') + status, data = conn.uid("SEARCH", None, f'(SINCE {since})') if status == "OK" and data[0]: - for u in data[0].split()[-30:]: + for u in reversed(data[0].split()[-30:]): uid_list.append((folder, u)) except Exception as _e: logger.warning(f"Folder {folder} scan failed: {_e}") + # Some IMAP servers/accounts give unreliable results for SINCE + # because of INTERNALDATE/date-header quirks. If the user manually + # runs a cacheable email task and SINCE finds nothing, fall back to + # the latest visible inbox messages so Clear cache -> Run again can + # actually repopulate AI reply/summary/tag caches. + if not uid_list: + try: + conn.select("INBOX", readonly=True) + status, data = conn.uid("SEARCH", None, "ALL") + if status == "OK" and data and data[0]: + for u in reversed(data[0].split()[-8:]): + uid_list.append(("INBOX", u)) + logger.info("Email task SINCE scan found no messages; fell back to latest INBOX messages") + except Exception as _e: + logger.warning(f"Latest-INBOX fallback scan failed: {_e}") # Re-select INBOX as default for downstream code conn.select("INBOX", readonly=True) if not uid_list: conn.logout() return "No recent emails" + await _emit_progress(progress_cb, f"Found {len(uid_list)} recent email(s); checking cache…") _c = _sql3.connect(SCHEDULED_DB) _sum_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_summaries").fetchall()} @@ -198,10 +230,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None too_short = 0 no_msgid = 0 examined = 0 + _summaries_created = 0 _events_created = 0 + _replies_drafted = 0 + _reply_failed = 0 + _detail_lines = [] _current_folder = "INBOX" + _max_process = 5 for _entry in uid_list: - if processed >= 10: + if processed >= _max_process: break # entry can be either a bare UID (legacy callers) or (folder, uid) tuple (new code) if isinstance(_entry, tuple): @@ -212,7 +249,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None if _folder != _current_folder: conn.select(_q(_folder), readonly=True) _current_folder = _folder - st, msg_data = conn.fetch(uid, "(RFC822)") + st, msg_data = conn.uid("FETCH", uid if isinstance(uid, bytes) else str(uid).encode(), "(RFC822)") if st != "OK": continue examined += 1 @@ -253,6 +290,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None and not _is_self_mail) if not need_sum and not need_reply and not need_class and not need_cal and not need_urgent: already_cached += 1 + await _emit_progress(progress_cb, f"Checked {examined}/{len(uid_list)} · {already_cached} already cached") continue subject = _decode_header(msg.get("Subject", "")) sender = _decode_header(msg.get("From", "")) @@ -267,12 +305,16 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None att_text = _extract_attachment_text(msg, max_chars=6000) except Exception as _ae: logger.debug(f"attachment text extraction failed for uid={uid}: {_ae}") - # No threshold for calendar — even "see you tmrw 5pm" matters. - # Summary/reply/classify still need ≥100 chars to be worth the LLM cost. + # No threshold for calendar or reply drafting — even "can you + # confirm?" needs a reply. Summary/classify still need enough + # text to be worth the LLM cost. # If body is short but attachments have content, treat it as enough. if need_cal: if not body: body = subject # at minimum send the subject line + elif need_reply: + if not body: + body = subject elif (not body or len(body) < 100) and not att_text: too_short += 1 continue @@ -317,16 +359,26 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None _c.execute(""" INSERT OR REPLACE INTO email_summaries (message_id, uid, folder, subject, sender, summary, model_used, created_at) - VALUES (?, ?, 'INBOX', ?, ?, ?, ?, ?) - """, (message_id, uid.decode(), subject, sender, summary, model, datetime.utcnow().isoformat())) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender, summary, model, datetime.utcnow().isoformat())) _c.commit() _c.close() _sum_existing.add(message_id) + _summaries_created += 1 + _uid_text = uid.decode() if isinstance(uid, bytes) else str(uid) + _detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}") except Exception as e: + _uid_text = uid.decode() if isinstance(uid, bytes) else str(uid) + _detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}") logger.warning(f"Auto-summary {uid} failed: {e}") if need_reply: - context_snippets, _terms = _pre_retrieve_context(body, sender) + await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}") + # Background reply drafting should not make the whole app + # feel busy. Keep it lightweight: no extra IMAP context + # mining here; manual AI Reply can still do that when the + # user explicitly asks for a draft on one email. + context_snippets, _terms = [], [] sys_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE if att_text: sys_prompt += "\n\nThe email has attachments (PDFs / docs) — their contents follow the body marked '--- ATTACHMENTS ---'. Reference them in your reply when relevant (e.g. acknowledge the invoice/contract, address specific clauses or amounts)." @@ -341,8 +393,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None {"role": "system", "content": sys_prompt}, {"role": "user", "content": f"Original email:\nFrom: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\nDraft a reply. Return only the reply body text."}, ], - temperature=0.7, max_tokens=16384, - headers=req_headers, timeout=240, + temperature=0.7, max_tokens=1024, + headers=req_headers, timeout=90, ) reply = _apply_email_style_mechanics(_extract_reply(reply or "")) if reply: @@ -350,12 +402,20 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None _c.execute(""" INSERT OR REPLACE INTO email_ai_replies (message_id, uid, folder, reply, model_used, created_at) - VALUES (?, ?, 'INBOX', ?, ?, ?) - """, (message_id, uid.decode(), reply, model, datetime.utcnow().isoformat())) + VALUES (?, ?, ?, ?, ?, ?) + """, (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, reply, model, datetime.utcnow().isoformat())) _c.commit() _c.close() _reply_existing.add(message_id) + _replies_drafted += 1 + _uid_text = uid.decode() if isinstance(uid, bytes) else str(uid) + _detail_lines.append(f"reply · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}") + await _emit_progress(progress_cb, f"Drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies") + f" · checked {examined}/{len(uid_list)}") except Exception as e: + _reply_failed += 1 + _uid_text = uid.decode() if isinstance(uid, bytes) else str(uid) + _detail_lines.append(f"reply failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}") + await _emit_progress(progress_cb, f"Reply failed {_reply_failed} · checked {examined}/{len(uid_list)}") logger.warning(f"Auto-reply {uid} failed: {e}") # ── Calendar event extraction (independent of reply drafting) ── @@ -805,6 +865,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None continue conn.logout() + await _emit_progress(progress_cb, "Finishing…") if processed > 0: logger.info(f"Auto-processed {processed} new email(s) for summary/reply/classify") # Build a clear status message @@ -817,6 +878,12 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None parts = [f"Scanned {len(uid_list)} email(s) ({ops_label})"] if processed: parts.append(f"processed {processed} new") + if auto_sum: + parts.append(f"summarized {_summaries_created}") + if auto_reply: + parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies")) + if _reply_failed: + parts.append(f"{_reply_failed} reply failed") if already_cached: parts.append(f"{already_cached} already cached") if too_short: @@ -827,7 +894,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None parts.append(f"created {_events_created} calendar event(s)") if processed == 0 and already_cached == 0 and too_short == 0: parts.append("nothing to do") - return " · ".join(parts) + summary = " · ".join(parts) + if _detail_lines: + summary += "\n\nProcessed:\n" + "\n".join(f"- {line}" for line in _detail_lines[:20]) + return summary except Exception as e: logger.warning(f"Auto-summarize pass error: {e}") return f"Error: {e}" diff --git a/routes/email_routes.py b/routes/email_routes.py index f39fa11..94ce9dc 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -1198,7 +1198,7 @@ def setup_email_routes(): (message_id.strip(),), ).fetchone() if _row2: - cached_ai_reply = _row2[0] + cached_ai_reply = _apply_email_style_mechanics(_extract_reply(_row2[0] or "")) _row3 = _c.execute( "SELECT sig_start, quote_start, turns_json FROM email_boundaries WHERE message_id = ?", (message_id.strip(),), @@ -1254,6 +1254,7 @@ def setup_email_routes(): return { "uid": uid, + "folder": folder, "message_id": message_id.strip(), "subject": subject, "from_name": sender_name or sender_addr, @@ -2539,10 +2540,31 @@ def setup_email_routes(): message_id = (data.get("message_id") or "").strip() source_uid = (data.get("uid") or "").strip() source_folder = (data.get("folder") or "INBOX").strip() + fast_reply = bool(data.get("fast", False)) if not original_body: return {"success": False, "error": "No email body provided"} + if message_id: + try: + _c = _sql3.connect(SCHEDULED_DB) + _row = _c.execute( + "SELECT reply, model_used FROM email_ai_replies WHERE message_id = ?", + (message_id,), + ).fetchone() + _c.close() + if _row and _row[0]: + cached_reply = _apply_email_style_mechanics(_extract_reply(_row[0] or "")) + if cached_reply: + return { + "success": True, + "reply": cached_reply, + "model_used": _row[1] or "cached", + "cached": True, + } + except Exception as e: + logger.warning(f"AI reply cache lookup failed: {e}") + settings = _load_settings() style = settings.get("email_writing_style", "") @@ -2618,8 +2640,12 @@ def setup_email_routes(): logger.info(f"AI reply using model={model} url={url}") - # Pre-retrieval: mine names/topics from the original email, search past mail + contacts - context_snippets, _terms = _pre_retrieve_context(original_body, to) + # Manual AI Reply should feel immediate. The heavier context mining + # can involve multiple IMAP folder searches and attachment parsing; + # reserve that for callers that explicitly opt out of fast mode. + context_snippets, _terms = ([], []) + if not fast_reply: + context_snippets, _terms = _pre_retrieve_context(original_body, to) # NEW: also pull the last few emails from the original sender + # their attachments. The "to" field on this endpoint is the @@ -2627,16 +2653,17 @@ def setup_email_routes(): # sender we're answering. So `to` doubles as the address we want # the thread context for. referenced = "" - try: - from_addr_for_ctx = email.utils.parseaddr(to or "")[1] - referenced = _fetch_sender_thread_context( - sender_addr=from_addr_for_ctx, - exclude_uid=source_uid, - exclude_folder=source_folder, - limit=3, - ) - except Exception as _e: - logger.warning(f"sender-thread-context failed: {_e}") + if not fast_reply: + try: + from_addr_for_ctx = email.utils.parseaddr(to or "")[1] + referenced = _fetch_sender_thread_context( + sender_addr=from_addr_for_ctx, + exclude_uid=source_uid, + exclude_folder=source_folder, + limit=3, + ) + except Exception as _e: + logger.warning(f"sender-thread-context failed: {_e}") system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE if style: @@ -2705,12 +2732,8 @@ def setup_email_routes(): {"role": "user", "content": user_msg}, ], temperature=0.7, - # Match the background poller's reply budget (16384). The old - # 4096 cap let a local reasoning model (Qwen3 / R1) spend the - # whole budget inside , so _strip_think left nothing — - # surfacing as "LLM returned empty response". - max_tokens=16384, - timeout=300, + max_tokens=1024 if fast_reply else 6144, + timeout=60 if fast_reply else 180, ) except Exception as e: detail = getattr(e, "detail", None) or str(e) @@ -2724,7 +2747,6 @@ def setup_email_routes(): # Cache so next click is instant if message_id: try: - import sqlite3 as _sql3 _c = _sql3.connect(SCHEDULED_DB) _c.execute(""" INSERT OR REPLACE INTO email_ai_replies diff --git a/routes/task_routes.py b/routes/task_routes.py index ad988e0..baa903b 100644 --- a/routes/task_routes.py +++ b/routes/task_routes.py @@ -427,6 +427,79 @@ def setup_task_routes(task_scheduler) -> APIRouter: notes = task_scheduler.pop_notifications(owner=user) return {"notifications": notes} + @router.post("/{task_id}/clear-cache") + async def clear_task_cache(request: Request, task_id: str): + """Clear derived cache for one built-in task.""" + user = _owner(request) + db = SessionLocal() + try: + task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() + if not task: + raise HTTPException(404, "Task not found") + if user and task.owner != user: + raise HTTPException(403, "Access denied") + action = task.action or "" + finally: + db.close() + + cache_tables = { + "summarize_emails": ("email_summaries",), + "draft_email_replies": ("email_ai_replies",), + "extract_email_events": ("email_calendar_extractions",), + "mark_email_boundaries": ("email_boundaries",), + "learn_sender_signatures": ("sender_signatures",), + "check_email_urgency": ("email_tags", "email_urgency_alerts"), + } + tables = cache_tables.get(action) + if not tables: + raise HTTPException(400, "This task has no clearable cache") + + import sqlite3 + from pathlib import Path + from routes.email_helpers import SCHEDULED_DB + + cleared = {} + conn = sqlite3.connect(SCHEDULED_DB) + try: + for table in tables: + try: + if table == "email_tags" and user: + before = conn.execute( + "SELECT COUNT(*) FROM email_tags WHERE owner = ? OR owner = ''", + (user,), + ).fetchone()[0] + conn.execute("DELETE FROM email_tags WHERE owner = ? OR owner = ''", (user,)) + else: + before = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + conn.execute(f"DELETE FROM {table}") + cleared[table] = int(before or 0) + except sqlite3.OperationalError: + cleared[table] = 0 + conn.commit() + finally: + conn.close() + + removed_files = 0 + if action == "check_email_urgency": + cache_dir = Path("data/email_urgency_cache") + if cache_dir.exists(): + for child in cache_dir.glob("*.json"): + try: + child.unlink() + removed_files += 1 + except Exception: + pass + owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (user or "default")) + for state_path in [Path(f"data/email_urgency_state_{owner_slug}.json")]: + try: + if state_path.exists(): + state_path.unlink() + removed_files += 1 + except Exception: + pass + + return {"ok": True, "action": action, "cleared": cleared, "files": removed_files} + @router.get("/{task_id}") async def get_task(request: Request, task_id: str): user = _owner(request) @@ -638,6 +711,23 @@ def setup_task_routes(task_scheduler) -> APIRouter: raise HTTPException(409, "Task is already running") return {"ok": True, "message": "Task triggered" + (" in parallel" if force else "")} + @router.post("/{task_id}/stop") + async def stop_task_now(request: Request, task_id: str): + user = _owner(request) + db = SessionLocal() + try: + task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() + if not task: + raise HTTPException(404, "Task not found") + if user and task.owner != user: + raise HTTPException(403, "Access denied") + finally: + db.close() + stopped = await task_scheduler.stop_task(task_id) + if not stopped: + raise HTTPException(404, "Task is not running") + return {"ok": True, "message": "Task stopped"} + @router.get("/runs/recent") async def list_recent_runs(request: Request, limit: int = 50): """Recent task runs across ALL tasks for this owner. Drives the Activity view.""" diff --git a/src/builtin_actions.py b/src/builtin_actions.py index 2ac90ed..711c7eb 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -469,7 +469,12 @@ async def action_draft_email_replies(owner: str, **kwargs) -> Tuple[str, bool]: """Run one pass of AI reply drafting.""" try: from routes.email_pollers import _run_auto_summarize_once - result = await _run_auto_summarize_once(do_summary=False, do_reply=True) + result = await _run_auto_summarize_once( + do_summary=False, + do_reply=True, + days_back=7, + progress_cb=kwargs.get("progress_cb"), + ) if not _result_has_work(result): raise TaskNoop(f"draft replies: {result or 'no new emails'}") return result, True diff --git a/src/task_scheduler.py b/src/task_scheduler.py index 3343b10..581d0e5 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -222,6 +222,24 @@ class TaskScheduler: # This is a hard guarantee, not configurable. self._run_semaphore = asyncio.Semaphore(1) self._concurrency_cap = 1 + self._task_handles = {} + + def _set_run_progress(self, run_id: str, message: str): + """Persist short live progress text for Activity while a run is active.""" + if not run_id: + return + try: + from core.database import SessionLocal, TaskRun + db = SessionLocal() + try: + run = db.query(TaskRun).filter(TaskRun.id == run_id).first() + if run and run.status in ("queued", "running"): + run.result = (message or "")[:4000] + db.commit() + finally: + db.close() + except Exception: + logger.debug("Task progress update failed", exc_info=True) def add_notification(self, task_name: str, status: str, task_id: str = None, owner: str = None, body: str = None): """Store a notification about a completed task run. Tagged with the @@ -516,6 +534,9 @@ class TaskScheduler: # line behind another. Once we acquire the slot, flip to "running" # and hand off to _execute_task_locked. from core.database import SessionLocal, TaskRun + current = asyncio.current_task() + if current: + self._task_handles[task_id] = current run_id = str(uuid.uuid4()) _q_db = SessionLocal() try: @@ -524,6 +545,7 @@ class TaskScheduler: task_id=task_id, started_at=datetime.utcnow(), status="queued", + result="Queued — waiting for a free slot…", ) _q_db.add(run) _q_db.commit() @@ -563,6 +585,7 @@ class TaskScheduler: if run: run.status = "running" run.started_at = datetime.utcnow() + run.result = "Starting…" db.commit() else: # Defensive: row may have been wiped; recreate so the rest of @@ -572,6 +595,7 @@ class TaskScheduler: task_id=task.id, started_at=datetime.utcnow(), status="running", + result="Starting…", ) db.add(run) db.commit() @@ -586,7 +610,7 @@ class TaskScheduler: self._last_run_model = None try: if task_type == "action": - result, success = await self._execute_action(task) + result, success = await self._execute_action(task, run_id=run_id) run.status = "success" if success else "error" run.result = result if not success: @@ -622,6 +646,27 @@ class TaskScheduler: task.next_run = when db.commit() return + except asyncio.CancelledError: + logger.info("Task '%s' stopped by user", task.name) + run_obj = db.query(TaskRun).filter(TaskRun.id == run_id).first() + if run_obj: + run_obj.status = "aborted" + run_obj.error = "Stopped by user" + run_obj.result = run_obj.result or "Stopped by user" + run_obj.finished_at = datetime.utcnow() + task.last_run = datetime.utcnow() + if (task.trigger_type or "schedule") == "schedule": + task.next_run = compute_next_run( + task.schedule, task.scheduled_time, + task.scheduled_day, task.scheduled_date, + after=datetime.utcnow(), + cron_expression=task.cron_expression, + tz_name=_resolve_task_timezone(db, task), + ) + else: + task.next_run = None + db.commit() + return except TaskNoop as noop: # Action reported "nothing to do". Mark the run as `skipped` # with the reason in `result` so it surfaces in Activity as a @@ -783,6 +828,9 @@ class TaskScheduler: logger.exception("Task %s error-path failed unexpectedly", task_id) finally: db.close() + handle = self._task_handles.get(task_id) + if handle is asyncio.current_task(): + self._task_handles.pop(task_id, None) if release_executing: async with self._executing_lock: self._executing.discard(task_id) @@ -853,7 +901,7 @@ class TaskScheduler: category=(task.name or "Task"), ) - async def _execute_action(self, task) -> tuple: + async def _execute_action(self, task, run_id: str | None = None) -> tuple: """Execute a built-in action (no LLM needed).""" from src.builtin_actions import BUILTIN_ACTIONS @@ -864,7 +912,10 @@ class TaskScheduler: from src.builtin_actions import TaskNoop try: # Pass task prompt as script/command for ssh_command/run_script actions. - kwargs = {"owner": task.owner, "task_name": task.name} + def _progress(message: str): + self._set_run_progress(run_id, message) + + kwargs = {"owner": task.owner, "task_name": task.name, "progress_cb": _progress} if task.action in ("run_script", "run_local", "ssh_command") and task.prompt: kwargs["script" if task.action in ("run_script", "run_local") else "command"] = task.prompt result, success = await action_fn(**kwargs) @@ -1752,6 +1803,38 @@ class TaskScheduler: asyncio.create_task(self._execute_task(task_id)) return True + async def stop_task(self, task_id: str) -> bool: + """Request cancellation of a running/queued task and mark its run aborted.""" + handle = self._task_handles.get(task_id) + stopped = False + if handle and not handle.done(): + handle.cancel() + stopped = True + async with self._executing_lock: + if task_id in self._executing: + self._executing.discard(task_id) + stopped = True + + from core.database import SessionLocal, TaskRun + db = SessionLocal() + try: + run = ( + db.query(TaskRun) + .filter(TaskRun.task_id == task_id, TaskRun.status.in_(("queued", "running"))) + .order_by(TaskRun.started_at.desc()) + .first() + ) + if run: + run.status = "aborted" + run.error = "Stopped by user" + run.result = run.result or "Stopped by user" + run.finished_at = datetime.utcnow() + db.commit() + stopped = True + finally: + db.close() + return stopped + async def ensure_defaults(self, owner: str): """Create default housekeeping tasks for this owner (idempotent per action).""" from core.database import SessionLocal, ScheduledTask diff --git a/static/index.html b/static/index.html index b7ff659..e9889dd 100644 --- a/static/index.html +++ b/static/index.html @@ -697,10 +697,9 @@
+
+

Email Tasks

+
+
Manage email background tasks in Tasks.
+ +
+
+

Writing Style

AI-extracted from your sent emails. Used when AI drafts replies.
diff --git a/static/js/document.js b/static/js/document.js index 2d8b8e4..0d0aa64 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -2306,6 +2306,48 @@ import * as Modals from './modalManager.js'; return r && r.style.display !== 'none' ? r : null; } + function _stripEmailReplyQuoteText(text) { + const original = String(text || ''); + if (!original) return { body: '', stripped: false }; + const lines = original.split('\n'); + const quoteIdx = lines.findIndex(line => + /^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim()) + || /^On .+ wrote:\s*$/i.test(line.trim()) + ); + if (quoteIdx <= 0) return { body: original.trim(), stripped: false }; + const body = lines.slice(0, quoteIdx).join('\n').trim(); + return { body, stripped: !!body }; + } + + function _emailReplyOwnText(text) { + return _stripEmailReplyQuoteText(text).body; + } + + function _setEmailBodyText(textarea, value) { + if (!textarea) return; + textarea.value = value || ''; + syncHighlighting(); + const rich = _emailRichbodyActive(); + if (rich) rich.innerHTML = _emailBodyToHtml(textarea.value); + } + + async function _streamEmailBodyText(textarea, value) { + if (!textarea) return; + const finalText = String(value || ''); + const maxFrames = 90; + const chunk = Math.max(8, Math.ceil(finalText.length / maxFrames)); + textarea.value = ''; + const rich = _emailRichbodyActive(); + if (rich) rich.innerHTML = ''; + for (let i = 0; i < finalText.length; i += chunk) { + const next = finalText.slice(0, i + chunk); + textarea.value = next; + if (rich) rich.innerHTML = _emailBodyToHtml(next); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + _setEmailBodyText(textarea, finalText); + } + function _focusEmailBodyEnd() { const target = _emailRichbodyActive() || document.getElementById('doc-editor-textarea'); if (!target) return; @@ -2795,10 +2837,12 @@ import * as Modals from './modalManager.js'; const references = document.getElementById('doc-email-references')?.value?.trim(); const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim(); const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX'; - const body = document.getElementById('doc-editor-textarea')?.value?.trim(); // WYSIWYG: the rich body's HTML becomes the email's HTML part (server // sanitizes it). `body` (plain text mirror) stays the text/plain fallback. const _rich = _emailRichbodyActive(); + if (_rich) _syncEmailRichbody(_rich); + const textarea = document.getElementById('doc-editor-textarea'); + const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim(); const bodyHtml = _rich ? _rich.innerHTML : null; const doc = docs.get(activeDocId); const attachments = (doc?._composeAtts || []).map(a => a.token); @@ -2806,6 +2850,10 @@ import * as Modals from './modalManager.js'; if (uiModule) uiModule.showError('To and body are required'); return; } + if (inReplyTo && !_emailReplyOwnText(body)) { + if (uiModule) uiModule.showError('Reply body is empty'); + return; + } // Warn if body mentions attachments but none are actually attached if (attachments.length === 0 && _bodyMentionsAttachment(body)) { const proceed = await _confirmMissingAttachment(); @@ -2829,12 +2877,13 @@ import * as Modals from './modalManager.js'; let canceled = false; if (uiModule) { uiModule.showToast('Sending', { - duration: 1200, + duration: 3200, + leadingIcon: 'spinner', action: 'Cancel', onAction: () => { canceled = true; }, }); } - await _sleep(1000); + await _sleep(3000); if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId); await _sleep(200); if (canceled) { @@ -2844,28 +2893,10 @@ import * as Modals from './modalManager.js'; return; } - let undone = false; - if (uiModule) { - uiModule.showToast('Message sent', { - duration: 2200, - leadingIcon: 'check', - action: 'Undo', - actionHint: 'undo send', - onAction: () => { undone = true; }, - }); - } - await _sleep(2200); - if (undone) { - _restoreDetachedEmailDoc(detachedEmailDoc); - detachedEmailDoc = null; - if (uiModule) uiModule.showToast('Send undone'); - return; - } - if (uiModule) uiModule.showToast('Sending...', 2000); - const activeAccountId = await _resolveComposeSendAccountId(); const res = await fetch(`${API_BASE}/api/email/send`, { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to, cc: cc || null, bcc: bcc || null, subject, body, body_html: bodyHtml, @@ -2875,7 +2906,13 @@ import * as Modals from './modalManager.js'; wait_for_delivery: true, }), }); - const data = await res.json(); + let data = null; + try { + data = await res.json(); + } catch (_) { + data = { success: false, error: `Send failed (${res.status})` }; + } + if (!res.ok && data && !data.error) data.error = `Send failed (${res.status})`; if (data.success) { if (uiModule) { uiModule.showToast('Message sent', { @@ -2961,8 +2998,10 @@ import * as Modals from './modalManager.js'; const subject = document.getElementById('doc-email-subject')?.value?.trim(); const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim(); const references = document.getElementById('doc-email-references')?.value?.trim(); - const body = document.getElementById('doc-editor-textarea')?.value?.trim(); const _rich = _emailRichbodyActive(); + if (_rich) _syncEmailRichbody(_rich); + const textarea = document.getElementById('doc-editor-textarea'); + const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim(); const bodyHtml = _rich ? _rich.innerHTML : null; const btn = document.getElementById('doc-email-draft-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; } @@ -3074,6 +3113,32 @@ import * as Modals from './modalManager.js'; const textarea = document.getElementById('doc-editor-textarea'); if (!textarea) return; const currentBody = textarea.value || ''; + const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim() || ''; + const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim() || ''; + const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX'; + const cleanAiReplyText = (text) => { + if (!text) return ''; + let t = String(text); + const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i; + const close = /<<<\s*END\s*>>+/i; + const m = open.exec(t); + if (m) { + const rest = t.slice(m.index + m[0].length); + const c = close.exec(rest); + t = c ? rest.slice(0, c.index) : rest; + } + return t + .replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '') + .replace(/<<<\s*END\s*>>+/gi, '') + .trim(); + }; + const shouldUseFastAiReply = () => { + const text = `${subject}\n${currentBody}`.toLowerCase(); + if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) { + return false; + } + return currentBody.length < 2500; + }; // Use the current chat model let currentModel = ''; @@ -3096,22 +3161,24 @@ import * as Modals from './modalManager.js'; original_body: currentBody, model: currentModel, session_id: currentSessionId, + message_id: inReplyTo, + uid: sourceUid, + folder: sourceFolder, + fast: shouldUseFastAiReply(), }), }); const data = await res.json(); if (data.success && data.reply) { + const cleanReply = cleanAiReplyText(data.reply); const lines = currentBody.split('\n'); const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:')); + let newBody = ''; if (quoteIdx > 0) { - const newBody = data.reply + '\n\n' + lines.slice(quoteIdx).join('\n'); - textarea.value = newBody; + newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n'); } else { - textarea.value = data.reply + (currentBody ? '\n\n' + currentBody : ''); + newBody = cleanReply + (currentBody ? '\n\n' + currentBody : ''); } - syncHighlighting(); - // Mirror into the WYSIWYG rich body if it's the active editor. - const _rb = _emailRichbodyActive(); - if (_rb) _rb.innerHTML = _emailBodyToHtml(textarea.value); + await _streamEmailBodyText(textarea, newBody); if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`); } else { if (uiModule) uiModule.showError(data.error || 'Failed to generate reply'); @@ -3130,7 +3197,12 @@ import * as Modals from './modalManager.js'; const subject = document.getElementById('doc-email-subject')?.value?.trim(); const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim(); const references = document.getElementById('doc-email-references')?.value?.trim(); - const body = document.getElementById('doc-editor-textarea')?.value?.trim(); + const _rich = _emailRichbodyActive(); + if (_rich) _syncEmailRichbody(_rich); + const body = (_rich + ? (_rich.innerText || _rich.textContent || '') + : (document.getElementById('doc-editor-textarea')?.value || '') + ).trim(); const doc = docs.get(activeDocId); const attachments = (doc?._composeAtts || []).map(a => a.token); @@ -3138,6 +3210,10 @@ import * as Modals from './modalManager.js'; if (uiModule) uiModule.showError('To and body are required'); return; } + if (inReplyTo && !_emailReplyOwnText(body)) { + if (uiModule) uiModule.showError('Reply body is empty'); + return; + } if (attachments.length === 0 && _bodyMentionsAttachment(body)) { const proceed = await _confirmMissingAttachment(); if (!proceed) return; @@ -5680,6 +5756,41 @@ import * as Modals from './modalManager.js'; })); } + export async function replaceEmailReplyBody(docId, replyText) { + const doc = docs.get(docId); + if (!doc) return; + const fields = _parseEmailHeader(doc.content || ''); + const lines = String(fields.body || '').split('\n'); + const quoteIdx = lines.findIndex(line => + /^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim()) + || /^On .+ wrote:\s*$/i.test(line.trim()) + ); + const quote = quoteIdx >= 0 ? lines.slice(quoteIdx).join('\n') : ''; + const ownText = _emailReplyOwnText(fields.body || ''); + if (ownText && !/^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText)) { + if (uiModule) uiModule.showToast('AI reply ready, but draft was edited'); + return; + } + const body = String(replyText || '').trim() + (quote ? `\n\n${quote}` : ''); + doc.content = _buildEmailContent( + fields.to, + fields.subject, + fields.inReplyTo, + fields.references, + body, + fields.sourceUid, + fields.sourceFolder, + fields.cc, + fields.bcc, + ); + if (activeDocId === docId) { + const textarea = document.getElementById('doc-editor-textarea'); + if (textarea) await _streamEmailBodyText(textarea, body); + } + clearTimeout(_autoSaveDebounce); + _autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800); + } + // Force the panel into a genuinely-open state. `isOpen` can be true while the // pane was torn down by another full-screen view (e.g. opening a doc from the // email modal): in that case openPanel() early-returns and nothing mounts, so diff --git a/static/js/emailInbox.js b/static/js/emailInbox.js index 2655b33..1d038af 100644 --- a/static/js/emailInbox.js +++ b/static/js/emailInbox.js @@ -26,6 +26,36 @@ const _starIcon = ' `${svg}`; +const _replySeparator = '---------- Previous message ----------'; + +function _cleanAiReplyText(text) { + if (!text) return ''; + let t = String(text); + const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i; + const close = /<<<\s*END\s*>>+/i; + const m = open.exec(t); + if (m) { + const rest = t.slice(m.index + m[0].length); + const c = close.exec(rest); + t = c ? rest.slice(0, c.index) : rest; + } + return t + .replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '') + .replace(/<<<\s*END\s*>>+/gi, '') + .trim(); +} + +function _shouldUseFastAiReply(data) { + const body = String(data?.body || data?.body_html || ''); + const subject = String(data?.subject || ''); + const atts = Array.isArray(data?.attachments) ? data.attachments : []; + if (atts.length > 0) return false; + const text = `${subject}\n${body}`.toLowerCase(); + if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) { + return false; + } + return body.length < 2500; +} let _emails = []; let _currentFolder = 'INBOX'; @@ -609,52 +639,9 @@ function _createEmailItem(em) { } async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') { - // If AI Reply mode: use cached reply if available, otherwise generate + const wantsAiReply = mode === 'ai-reply'; let aiSuggestedBody = null; - if (mode === 'ai-reply' && preloadedData) { - const data = preloadedData; - // Check for pre-generated cached reply first (instant!) - if (data.cached_ai_reply) { - aiSuggestedBody = data.cached_ai_reply; - } else { - // No cache — generate on demand - try { - let currentModel = ''; - let currentSessionId = ''; - try { - currentModel = sessionModule?.getCurrentModel() || ''; - currentSessionId = sessionModule?.getCurrentSessionId() || ''; - } catch (_) {} - const res = await fetch(`${API_BASE}/api/email/ai-reply`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - to: data.from_address, - subject: `Re: ${data.subject}`, - original_body: data.body, - model: currentModel, - session_id: currentSessionId, - message_id: data.message_id || '', - uid: String(em.uid || ''), - folder: _currentFolder, - }), - }); - const result = await res.json(); - if (result.success && result.reply) { - aiSuggestedBody = result.reply; - } else { - // Don't silently open a blank draft — tell the user it failed so a - // model/endpoint problem (e.g. empty response) is visible. - // uiModule isn't statically imported here; use the dynamic pattern. - const _msg = result.error || 'AI reply could not be generated'; - console.error('AI reply generation failed:', _msg); - import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {}); - } - } catch (e) { - console.error('AI reply generation failed:', e); - import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {}); - } - } + if (wantsAiReply) { // Fall through to reply-all (not plain reply) so the generated AI // draft addresses everyone on the original thread. On single- // recipient emails this collapses to a regular reply since there's @@ -682,6 +669,54 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') { console.error('Failed to read email:', data.error); return; } + if (wantsAiReply) { + if (data.cached_ai_reply) { + aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply); + } else { + let draftToastTimer = null; + draftToastTimer = setTimeout(() => { + import('./ui.js').then(m => m.showToast && m.showToast('Drafting AI reply', { duration: 3000, leadingIcon: 'spinner' })).catch(() => {}); + }, 450); + try { + let currentModel = ''; + let currentSessionId = ''; + try { + currentModel = sessionModule?.getCurrentModel() || ''; + currentSessionId = sessionModule?.getCurrentSessionId() || ''; + } catch (_) {} + const res = await fetch(`${API_BASE}/api/email/ai-reply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: data.from_address, + subject: `Re: ${data.subject}`, + original_body: data.body, + model: currentModel, + session_id: currentSessionId, + message_id: data.message_id || '', + uid: String(em.uid || ''), + folder: _currentFolder, + fast: _shouldUseFastAiReply(data), + }), + }); + const result = await res.json(); + if (draftToastTimer) clearTimeout(draftToastTimer); + if (result.success && result.reply) { + aiSuggestedBody = _cleanAiReplyText(result.reply); + } else { + const _msg = result.error || 'AI reply could not be generated'; + console.error('AI reply generation failed:', _msg); + import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {}); + return; + } + } catch (e) { + if (draftToastTimer) clearTimeout(draftToastTimer); + console.error('AI reply generation failed:', e); + import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {}); + return; + } + } + } em.is_read = true; if (itemEl) itemEl.classList.remove('email-unread'); @@ -772,7 +807,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') { } else { content += '\n\n'; } - content += `On ${niceDate}, ${data.from_name} <${data.from_address}> wrote:\n${quotedBody}`; + content += `${_replySeparator}\nOn ${niceDate}, ${data.from_name} <${data.from_address}> wrote:\n${quotedBody}`; } if (_docModule) { diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index e1a4fb6..b391f7e 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -84,8 +84,6 @@ window.addEventListener('email-answered', (e) => { function _toggleUnreadEmails() { if (state._libFolder === '__scheduled__') state._libFolder = 'INBOX'; state._libFilter = state._libFilter === 'unread' ? 'all' : 'unread'; - state._libOffset = 0; - state._libEmails = []; _syncUnreadWindowGlow(); const folderEl = document.getElementById('email-lib-folder'); const filterEl = document.getElementById('email-lib-filter'); @@ -93,7 +91,7 @@ function _toggleUnreadEmails() { if (filterEl) filterEl.value = state._libFilter; document.getElementById('email-undone-btn')?.classList.remove('active'); document.getElementById('email-reminder-btn')?.classList.remove('active'); - _loadEmails(); + _loadEmailsFresh(); } function _syncUnreadTabBadge(count) { @@ -433,6 +431,22 @@ function _libCachePut(key, value) { } } +function _resetEmailListForFreshLoad() { + state._libOffset = 0; + state._libEmails = []; + state._libTotal = 0; + _libLoadSeq += 1; + const grid = document.getElementById('email-lib-grid'); + if (grid) grid.innerHTML = ''; + const stats = document.getElementById('email-lib-stats'); + if (stats) stats.textContent = 'Loading...'; +} + +function _loadEmailsFresh() { + _resetEmailListForFreshLoad(); + return _loadEmails({ force: true, useCache: false }); +} + export function prewarmEmailLibrary({ delay = 2500 } = {}) { if (_libPrewarmTimer || _libPrewarmPromise) return; const elapsed = Date.now() - _libLastPrewarmAt; @@ -742,17 +756,13 @@ export function openEmailLibrary(opts = {}) { document.getElementById('email-lib-folder').addEventListener('change', (e) => { state._libFolder = e.target.value; - state._libOffset = 0; - state._libEmails = []; - _loadEmails(); + _loadEmailsFresh(); }); document.getElementById('email-lib-filter').addEventListener('change', (e) => { state._libFilter = e.target.value; - state._libOffset = 0; - state._libEmails = []; _syncUnreadWindowGlow(); _syncReminderClearButton(); - _loadEmails(); + _loadEmailsFresh(); // Sync quick-toggle active states so they mirror the dropdown. document.getElementById('email-undone-btn')?.classList.toggle('active', state._libFilter === 'undone'); document.getElementById('email-reminder-btn')?.classList.toggle('active', state._libFilter === 'reminders'); @@ -761,10 +771,8 @@ export function openEmailLibrary(opts = {}) { const btn = document.getElementById('email-attach-btn'); state._libHasAttachments = !state._libHasAttachments; btn?.classList.toggle('active', state._libHasAttachments); - state._libOffset = 0; - state._libEmails = []; _syncReminderClearButton(); - _loadEmails(); + _loadEmailsFresh(); }); document.getElementById('email-reminders-clear-btn')?.addEventListener('click', async () => { const ok = await styledConfirm('Permanently delete all Odysseus reminder emails?', { @@ -790,10 +798,8 @@ export function openEmailLibrary(opts = {}) { const filterEl = document.getElementById('email-lib-filter'); if (filterEl) filterEl.value = 'all'; document.getElementById('email-reminder-btn')?.classList.remove('active'); - state._libOffset = 0; - state._libEmails = []; _syncReminderClearButton(); - _loadEmails(); + _loadEmailsFresh(); } catch (err) { console.error(err); showToast('Failed to clear reminder emails'); @@ -812,11 +818,9 @@ export function openEmailLibrary(opts = {}) { btn.classList.add('active'); document.getElementById('email-reminder-btn')?.classList.remove('active'); } - state._libOffset = 0; - state._libEmails = []; _syncUnreadWindowGlow(); _syncReminderClearButton(); - _loadEmails(); + _loadEmailsFresh(); }); document.getElementById('email-reminder-btn')?.addEventListener('click', () => { const btn = document.getElementById('email-reminder-btn'); @@ -831,11 +835,9 @@ export function openEmailLibrary(opts = {}) { btn.classList.add('active'); document.getElementById('email-undone-btn')?.classList.remove('active'); } - state._libOffset = 0; - state._libEmails = []; _syncUnreadWindowGlow(); _syncReminderClearButton(); - _loadEmails(); + _loadEmailsFresh(); }); // The old "sort" dropdown (Latest / Unread first / Favorites first) was merged // into the filter dropdown above — "Favorites" is now a filter (server-side @@ -1081,8 +1083,6 @@ function _renderAccountsStrip() { const strip = document.getElementById('email-lib-accounts'); if (!strip) return; strip.style.display = 'flex'; - // No accounts loaded yet — leave the row empty (New button still shows alongside). - if (!state._libAccounts.length) { strip.innerHTML = ''; return; } const esc = s => String(s || '').replace(/&/g, '&').replace(/All (default)`; @@ -1096,11 +1096,10 @@ function _renderAccountsStrip() { btn.addEventListener('click', async () => { state._libAccountId = btn.dataset.accId || null; _publishActiveAccount(); - state._libOffset = 0; - state._libEmails = []; + _resetEmailListForFreshLoad(); _renderAccountsStrip(); await _loadFolders({ resetMissing: true }); - _loadEmails({ force: true }); + _loadEmails({ force: true, useCache: false }); }); }); _publishActiveAccount(); @@ -1358,7 +1357,7 @@ async function _refreshUnreadBadge() { } catch (_) { _syncUnreadTabBadge(0); } } -async function _loadEmails({ force = false } = {}) { +async function _loadEmails({ force = false, useCache = true } = {}) { const seq = ++_libLoadSeq; state._libLoading = true; const accountAtStart = state._libAccountId || ''; @@ -1375,15 +1374,16 @@ async function _loadEmails({ force = false } = {}) { // paint the cached list immediately (no spinner, no blank grid) and // then quietly refetch behind it. Pagination, search, and the // scheduled virtual folder skip the cache and use the old spinner - // path. `force` (Refresh button) still consults the cache for + // path. `force` (Refresh button) can still consult the cache for // perceptual continuity, but adds a cache-buster so the server's 8s - // list cache is bypassed too. + // list cache is bypassed too. Account/folder/filter changes pass + // `useCache: false` so stale rows from the previous view never flash. const cacheable = offsetAtStart === 0 && !searchAtStart && folderAtStart !== '__scheduled__'; const ck = cacheable ? _libCacheKey() : null; - const cached = cacheable ? _libCacheGet(ck) : null; + const cached = (useCache && cacheable) ? _libCacheGet(ck) : null; let sp = null; if (cached) { @@ -1881,6 +1881,9 @@ function _prefetchAdjacentEmails(card, count = 3) { } async function _toggleCardPreview(card, em) { + const accountAtStart = state._libAccountId || ''; + const folderAtStart = state._libFolder || 'INBOX'; + const uidAtStart = String(em?.uid || card?.dataset?.uid || ''); const grid = card.closest('.doclib-grid'); const gridRect = grid?.getBoundingClientRect?.(); const modal = document.getElementById('email-lib-modal'); @@ -1921,7 +1924,7 @@ async function _toggleCardPreview(card, em) { card.style.minHeight = `${Math.round(stableOpenHeight)}px`; if (!em.is_read) { _syncEmailReadState(em.uid, true); - fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }) + fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`, { method: 'POST' }) .catch(err => console.error('Failed to mark email read:', err)); } // Class hook on the modal so the header-hide / padding rules work on @@ -1944,8 +1947,17 @@ async function _toggleCardPreview(card, em) { card.appendChild(reader); try { - const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`); + const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`); const data = await res.json(); + if ( + accountAtStart !== (state._libAccountId || '') || + folderAtStart !== (state._libFolder || 'INBOX') || + uidAtStart !== String(card?.dataset?.uid || '') || + !card.isConnected || + !card.classList.contains('email-card-expanded') + ) { + return; + } if (data.error) { reader.innerHTML = `
Error: ${_esc(data.error)}
`; return; @@ -2013,7 +2025,7 @@ async function _toggleCardPreview(card, em) {
${attsHtml} - + `; reader.classList.remove('email-card-reader-loading'); reader.style.minHeight = ''; @@ -2252,6 +2264,23 @@ function _setBubblesDisabled(v) { } function _renderEmailBody(data) { + const plain = (typeof data?.body === 'string' && data.body.length) ? data.body : ''; + const folder = String(data?.folder || '').toLowerCase(); + const isSentFolder = folder.includes('sent'); + const fromAddr = String(data?.from_address || '').toLowerCase().trim(); + const isMine = !!fromAddr && _meEmailAddrs().has(fromAddr); + + // Messages authored by the user (Sent folder or self-sent copies in INBOX) + // are current authored text. Do not let cached boundaries or HTML + // blockquote parsing hide the whole thing behind "Earlier reply". + if ((isSentFolder || isMine) && plain) { + const plainTurns = _renderPlaintextThread(plain); + if (plainTurns && !/^\s*'), null); + } + // Prefer the server-cached thread parse — that's the richest structure // and the one the chat-bubble layout is built around. Skip when the user // has manually disabled bubble rendering. @@ -2263,7 +2292,6 @@ function _renderEmailBody(data) { } const b = data && data.boundaries; // Use cached boundaries when present AND we have plain-text body to slice - const plain = (typeof data.body === 'string' && data.body.length) ? data.body : ''; if (b && plain && (b.sig_start >= 0 || b.quote_start >= 0)) { // Pick the EARLIER of the two as the cut for "everything below this is // foldable", but render sig and quote with their own labels. @@ -2327,6 +2355,18 @@ function _renderEmailBody(data) { return _foldSignature(_foldQuotedReplies(rendered), hintSig); } +function _safeRenderEmailBody(data) { + try { + return _renderEmailBody(data); + } catch (e) { + console.error('email body render failed:', e); + const plain = (typeof data?.body === 'string') ? data.body : ''; + if (plain) return _escLinkify(plain).replace(/\n/g, '
'); + if (data?.body_html) return _sanitizeHtml(data.body_html); + return 'No body'; + } +} + // ── Chat-bubble rendering for email threads ── // Each parsed turn renders as a chat bubble. Bubbles for the active // account's outgoing replies align right; everyone else aligns left. @@ -2636,12 +2676,13 @@ function _renderPlaintextThread(text) { const lvl = levels[i]; const raw = lines[i]; const stripped = lvl > 0 ? raw.replace(/^(?:>\s?)+/, '') : raw; + const isSeparatorLine = lvl === 0 && /^-{5,}\s*Previous message\s*-{5,}$/i.test(raw.trim()); const isAttribLine = lvl === 0 && (new RegExp(`^\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'i').test(raw) || _TALON_ORIG_RE.test('\n' + raw)); - if (isAttribLine) { + if (isSeparatorLine || isAttribLine) { flush(); - pendingMeta = _extractQuoteMeta(raw) || raw.trim(); + pendingMeta = isSeparatorLine ? null : (_extractQuoteMeta(raw) || raw.trim()); curLevel = 1; continue; } @@ -3699,7 +3740,7 @@ async function _openEmailAsTab(em, folder) { ${attsHtml} - + `; try { _wireAttachmentHandlers(reader, useFolder); } catch {} const attsWrap = reader.querySelector('.email-reader-atts-wrap'); @@ -3854,7 +3895,7 @@ async function _openEmailWindow(em, folder) { ${attsHtml} - + `; // Wire all the same action handlers the inline reader has. try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {} @@ -3971,7 +4012,7 @@ async function _swapReaderToUid(reader, uid, folder) { } else if (oldAtts) { oldAtts.remove(); } - body.innerHTML = _renderEmailBody(data); + body.innerHTML = _safeRenderEmailBody(data); body.classList.toggle('html-body', !!data.body_html); // Wire click handlers for the newly-rendered attachment chips. Without // this, after swapping to a different email via the sidebar, clicking diff --git a/static/js/settings.js b/static/js/settings.js index d8a74e8..6f04140 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -2457,6 +2457,20 @@ async function initEmailAccountsSettings() { manageBtn.dataset.bound = '1'; manageBtn.addEventListener('click', () => open('integrations')); } + const tasksBtn = el('set-email-open-tasks'); + if (tasksBtn && tasksBtn.dataset.bound !== '1') { + tasksBtn.dataset.bound = '1'; + tasksBtn.addEventListener('click', async () => { + try { + const mod = await import('./tasks.js'); + const openTasks = mod.openTasks || (mod.default && mod.default.openTasks); + if (typeof openTasks === 'function') openTasks(); + else document.getElementById('tool-tasks-btn')?.click(); + } catch (_) { + document.getElementById('tool-tasks-btn')?.click(); + } + }); + } const listEl = el('set-email-accounts-list'); const msgEl = el('set-email-accounts-msg'); const formEl = el('set-email-accounts-form'); diff --git a/static/js/tasks.js b/static/js/tasks.js index 673f934..7c41aca 100644 --- a/static/js/tasks.js +++ b/static/js/tasks.js @@ -23,7 +23,7 @@ const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'S async function _fetchTasks() { try { - const res = await fetch(`${API_BASE}/api/tasks?include_last_run=true`, { credentials: 'same-origin' }); + const res = await fetch(`${API_BASE}/api/tasks`, { credentials: 'same-origin' }); const data = await res.json(); _tasks = data.tasks || []; } catch (e) { @@ -127,6 +127,21 @@ async function _runNow(id, force = false) { } } +async function _stopTask(id) { + const res = await fetch(`${API_BASE}/api/tasks/${id}/stop`, { + method: 'POST', + credentials: 'same-origin', + }); + if (!res.ok) { + let msg = `Failed to stop task (${res.status})`; + try { + const data = await res.json(); + if (data && data.detail) msg = data.detail; + } catch (_) {} + throw new Error(msg); + } +} + async function _fetchRuns(taskId, limit = 10) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/runs?limit=${limit}`, { credentials: 'same-origin', @@ -568,6 +583,19 @@ function _renderTaskChips() { for (const c of cats) mkChip(`${c} (${counts[c]})`, c, _taskFilter === c); } +const _TASK_CACHE_LABELS = { + summarize_emails: 'email summaries', + draft_email_replies: 'AI reply drafts', + extract_email_events: 'email calendar cache', + mark_email_boundaries: 'email boundaries', + learn_sender_signatures: 'sender signatures', + check_email_urgency: 'email tags', +}; + +function _taskClearCacheLabel(taskOrEntry) { + return _TASK_CACHE_LABELS[taskOrEntry?.action || ''] || ''; +} + function _renderList() { const list = document.getElementById('tasks-list'); if (!list) return; @@ -630,7 +658,7 @@ function _renderList() { const statusBadge = task.status === 'paused' ? ` paused` : task.status === 'active' - ? ` active` + ? `active` : ''; const builtinBadge = task.is_builtin ? `built-in${task.is_modified ? ' · edited' : ''}` @@ -659,6 +687,9 @@ function _renderList() { if (task.is_builtin && task.is_modified) { items.push({ label: 'Revert to default', icon: '', action: () => _doRevert(task.id) }); } + if (_taskClearCacheLabel(task)) { + items.push({ label: 'Clear cache', icon: '', action: () => _doClearTaskCache(task.id, _taskClearCacheLabel(task)) }); + } items.push({ label: 'Delete', icon: '', action: () => _doDelete(task.id), danger: true }); _showTaskDropdown(menuBtn, items); }); @@ -667,10 +698,10 @@ function _renderList() { // manual triggering. Hidden for completed tasks (same gate as before). if (task.status !== 'completed') { const runBtn = document.createElement('button'); - runBtn.className = 'memory-item-btn task-card-run-btn'; + runBtn.className = 'task-status-badge task-run-now-badge task-card-run-btn'; runBtn.title = 'Run now'; - runBtn.style.cssText = 'position:relative;top:4px;margin-right:4px;display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 6px;'; - runBtn.innerHTML = 'Run'; + runBtn.style.cssText = 'position:relative;top:1px;margin-right:4px;'; + runBtn.innerHTML = 'Run now'; runBtn.addEventListener('click', (e) => { e.stopPropagation(); _doRunNow(task.id); }); actionsWrap.insertBefore(runBtn, menuBtn); } @@ -1578,6 +1609,25 @@ async function _doRevert(id) { } catch (e) { if (uiModule) uiModule.showError(e.message); } } +async function _doClearTaskCache(id, label = 'cache') { + const ok = uiModule?.styledConfirm + ? await uiModule.styledConfirm(`Clear cached ${label} for this task?`, { confirmText: 'Clear' }) + : confirm(`Clear cached ${label} for this task?`); + if (!ok) return; + try { + const res = await fetch(`${API_BASE}/api/tasks/${encodeURIComponent(id)}/clear-cache`, { + method: 'POST', + credentials: 'same-origin', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`); + const n = Object.values(data.cleared || {}).reduce((a, b) => a + Number(b || 0), 0) + Number(data.files || 0); + if (uiModule) uiModule.showToast(`Cleared ${label}${n ? ` (${n})` : ''}`); + } catch (e) { + if (uiModule) uiModule.showError(`Clear cache failed: ${e.message || e}`); + } +} + async function _doToggleAll() { // If any task is active → pause all. Else resume all paused tasks. const hasActive = _tasks.some(t => t.status === 'active'); @@ -1680,10 +1730,6 @@ async function _renderActivityView() { document.getElementById('tasks-activity-refresh').addEventListener('click', _renderActivityView); - // Loading placeholder matches the document library: app whirlpool + label. - const _actList = document.getElementById('tasks-activity-list'); - if (_actList) _actList.appendChild(spinnerModule.createLoadingRow('Loading…')); - // Solo filter: clicking a chip shows ONLY that group (a category, or // Errors). Clicking the active chip again clears the filter (show all). // At most one chip is active at a time. _solo holds the active key, or null. @@ -1771,6 +1817,14 @@ async function _renderActivityView() { const searchEl = document.getElementById('tasks-activity-search'); if (searchEl) searchEl.addEventListener('input', () => { _afQuery = searchEl.value; _buildChips(); _applyFilter(); }); + const _actList = document.getElementById('tasks-activity-list'); + if (_activityEntries.length) { + _buildChips(); + _applyFilter(); + } else if (_actList) { + _actList.appendChild(spinnerModule.createLoadingRow('Loading…')); + } + try { const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=100`, { credentials: 'same-origin' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -1796,6 +1850,7 @@ async function _renderActivityView() { kind: r.task_type || 'llm', taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'), taskId: r.task_id, + action: r.action || '', result: resultText, prompt: '', ts: r.finished_at || r.started_at, @@ -1916,9 +1971,9 @@ function _wireActivityRows(list) { // counter). No-op when there's nothing to tick. _startActivityTimers(list); list.querySelectorAll('.task-log-row').forEach(row => { - // Click anywhere on the (non-running, non-skipped) row to toggle expand. + // Click anywhere on the row to toggle expand. // Buttons inside still get their own handlers via stopPropagation. - if (!row.classList.contains('is-running') && !row.classList.contains('is-skipped')) { + if (!row.classList.contains('is-skipped')) { row.addEventListener('click', () => row.classList.toggle('expanded')); } row.querySelector('.task-log-row-toggle')?.addEventListener('click', (e) => { @@ -1943,6 +1998,25 @@ function _wireActivityRows(list) { const entry = _activityEntries[idx]; if (entry?.taskId) _doRunNow(entry.taskId, true); }); + row.querySelector('.task-log-stop')?.addEventListener('click', async (e) => { + e.stopPropagation(); + const idx = parseInt(row.dataset.entryIdx, 10); + const entry = _activityEntries[idx]; + if (!entry?.taskId) return; + try { + await _stopTask(entry.taskId); + uiModule.showToast('Task stopped'); + _renderActivityView(); + } catch (err) { + uiModule.showError(err.message || 'Failed to stop task'); + } + }); + row.querySelector('.task-log-run-again')?.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(row.dataset.entryIdx, 10); + const entry = _activityEntries[idx]; + if (entry?.taskId) _doRunNow(entry.taskId); + }); row.querySelector('.task-log-copy')?.addEventListener('click', (e) => { e.stopPropagation(); const idx = parseInt(row.dataset.entryIdx, 10); @@ -1954,6 +2028,12 @@ function _wireActivityRows(list) { uiModule.showToast('Log copied'); } catch (_) { uiModule.showError('Copy failed'); } }); + row.querySelector('.task-log-clear-cache')?.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(row.dataset.entryIdx, 10); + const entry = _activityEntries[idx]; + if (entry?.taskId) _doClearTaskCache(entry.taskId, _taskClearCacheLabel(entry)); + }); }); } @@ -2113,13 +2193,11 @@ function _renderActivityEntry(entry) { const statusDot = ``; // Render the result through markdown so code blocks, lists, links look right. let resultHtml; - // Running / queued rows: body stays empty — the status now lives on the - // right side of the head row ("Running "), wired below. const _isRunning = entry.status === 'running' || entry.status === 'queued'; // Skipped (noop) rows: render as a slim, dimmed one-liner — no body, no // actions, just `· name · skipped — reason · time`. CSS via .is-skipped. const _isSkipped = entry.status === 'skipped'; - if (_isRunning) { + if (_isRunning && !(entry.result || '').trim()) { resultHtml = ''; } else { try { @@ -2155,6 +2233,7 @@ function _renderActivityEntry(entry) { // CSS vars feed the colored title + accent stripe. const styleVars = `--cat-hue:${hue};`; const hasResult = !!(entry.result && entry.result.trim() && entry.status !== 'running' && entry.status !== 'queued'); + const hasRunningProgress = !!(entry.result && entry.result.trim() && (entry.status === 'running' || entry.status === 'queued')); // "Open in chat" only makes sense for runs whose result is a real assistant // message (Prompt / Research tasks). Action/event runs are just log lines // (e.g. "No recent emails", "Tidied N memories") — for those, replace the @@ -2179,6 +2258,19 @@ function _renderActivityEntry(entry) { Copy log `; } + const clearLabel = _taskClearCacheLabel(entry); + if (hasResult && clearLabel && entry.taskId) { + actionBtn += ``; + } + if (hasResult && entry.taskId) { + actionBtn += ``; + } // Running rows replace the relative-time on the right with "Running NN" + a // live whirlpool spinner. Queued shows "Queued" the same way (no timer — // hasn't actually started yet). The elapsed counter ticks every second via @@ -2191,7 +2283,8 @@ function _renderActivityEntry(entry) { const startMs = entry.ts ? new Date(entry.ts).getTime() : Date.now(); const elapsedInit = isQueued ? '' : `${_fmtElapsed(Date.now() - startMs)}`; const forceBtn = isQueued && entry.taskId ? `` : ''; - rightHtml = `${label}${elapsedInit}${forceBtn}`; + const stopBtn = entry.taskId ? `` : ''; + rightHtml = `${label}${elapsedInit}${forceBtn}${stopBtn}`; } else { rightHtml = `${_escHtml(tsLabel)}`; } @@ -2223,7 +2316,7 @@ function _renderActivityEntry(entry) { ${rightHtml} - ${_isRunning ? '' : `
${resultHtml}
`} + ${(_isRunning && !hasRunningProgress) ? '' : `
${resultHtml}
`} ${promptHtml}
${long ? '' : ''} diff --git a/static/js/ui.js b/static/js/ui.js index dae3b62..a92e285 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -6,12 +6,15 @@ import themeModule from './theme.js'; import * as Modals from './modalManager.js'; +import spinnerModule from './spinner.js'; let toastEl = null; let autoScrollEnabled = true; let hoveredToggleCard = null; let hoveredToggleWindow = null; let hoveredDockChip = null; +let _lastPointerClientX = null; +let _lastPointerClientY = null; // Smooth scroll state let _scrollRafId = null; @@ -74,6 +77,66 @@ function _spaceWindowId(win) { return null; } +function _windowAtPointer() { + if (_lastPointerClientX == null || _lastPointerClientY == null) return null; + const x = _lastPointerClientX; + const y = _lastPointerClientY; + const candidates = [ + ...document.querySelectorAll('.modal:not(.hidden):not(.modal-minimized) .modal-content'), + ...document.querySelectorAll('.doc-editor-pane'), + ].filter(el => { + if (!document.contains(el)) return false; + const r = el.getBoundingClientRect(); + return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; + }); + if (!candidates.length) return null; + return candidates.reduce((top, el) => { + const mz = parseInt(getComputedStyle(el.closest('.modal') || el).zIndex, 10) || 0; + const tz = parseInt(getComputedStyle(top.closest('.modal') || top).zIndex, 10) || 0; + return mz >= tz ? el : top; + }); +} + +function _containsPointer(el) { + if (!el || _lastPointerClientX == null || _lastPointerClientY == null) return false; + const r = el.getBoundingClientRect(); + return _lastPointerClientX >= r.left && _lastPointerClientX <= r.right + && _lastPointerClientY >= r.top && _lastPointerClientY <= r.bottom; +} + +function _closeHoveredWindow() { + let win = _windowAtPointer(); + if (!win) { + try { + const underPointer = document.elementFromPoint(_lastPointerClientX, _lastPointerClientY); + win = underPointer?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane') || null; + } catch {} + } + if (!win) win = hoveredToggleWindow; + if (!win || !document.contains(win)) return false; + const modalForWin = win.closest?.('.modal[id]'); + if (modalForWin?.id === 'email-lib-modal') { + const closeBtn = document.getElementById('email-lib-close') || modalForWin.querySelector('.close-btn'); + if (closeBtn) { + try { closeBtn.click(); return true; } catch {} + } + try { modalForWin.remove(); return true; } catch {} + } + const id = _spaceWindowId(win); + if (id && Modals.isRegistered(id)) { + Modals.close(id); + return true; + } + const modal = _visibleModalForSpace(win); + if (!modal) return false; + const closeBtn = modal.querySelector('.close-btn, .modal-close, .modal-close-btn, [data-action="close"]'); + if (closeBtn) { + try { closeBtn.click(); return true; } catch {} + } + try { modal.classList.add('hidden'); return true; } catch {} + return false; +} + function _spaceIsBlocked(e, surface) { const target = _targetEl(e.target); if (!target) return false; @@ -103,6 +166,8 @@ function _initHoverCardSpaceToggle() { if (document._odysseusHoverCardSpaceToggle) return; document._odysseusHoverCardSpaceToggle = true; document.addEventListener('pointerover', (e) => { + _lastPointerClientX = e.clientX; + _lastPointerClientY = e.clientY; const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id]'); if (chip) hoveredDockChip = chip; const card = e.target?.closest?.(SPACE_CARD_SELECTOR); @@ -110,6 +175,10 @@ function _initHoverCardSpaceToggle() { const win = e.target?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane'); if (win) hoveredToggleWindow = win; }, true); + document.addEventListener('pointermove', (e) => { + _lastPointerClientX = e.clientX; + _lastPointerClientY = e.clientY; + }, true); document.addEventListener('pointerout', (e) => { const next = e.relatedTarget; if (hoveredDockChip && (!next || !hoveredDockChip.contains(next))) hoveredDockChip = null; @@ -252,6 +321,12 @@ export function showToast(msg, durationOrOpts) { icon.className = 'toast-checkmark'; icon.innerHTML = ''; toastEl.appendChild(icon); + } else if (leadingIcon === 'spinner') { + const wp = spinnerModule.createWhirlpool(14); + const icon = wp.element; + icon.classList.add('toast-whirlpool'); + icon.style.cssText = 'width:14px;height:14px;margin:0 8px 0 0;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;'; + toastEl.appendChild(icon); } textSpan.textContent = msg; toastEl.appendChild(textSpan); @@ -1114,8 +1189,6 @@ if (!window._odyEscExpandGuard) { document.addEventListener('keydown', (e) => { if (e.key !== 'Escape' || e.defaultPrevented) return; - const t = e.target; - if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; // Find the single thing to close, in priority order. The first hit wins. // Important: if a thinking block is open we MUST handle it ourselves and @@ -1123,6 +1196,12 @@ if (!window._odyEscExpandGuard) { // (the live-stream chat rebuilds thinking DOM mid-stream so the header // can briefly be absent). Toggling the `expanded` class directly is the // fallback so ESC never bypasses the thinking block to hit a modal. + if (_closeHoveredWindow()) { + e.stopImmediatePropagation(); e.preventDefault(); + return; + } + const t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; const expanded = document.querySelector('.doclib-card-expanded'); const think = document.querySelector('.thinking-content.expanded'); if (expanded) { diff --git a/static/style.css b/static/style.css index 260dbc2..e57b29e 100644 --- a/static/style.css +++ b/static/style.css @@ -3533,6 +3533,11 @@ body.bg-pattern-sparkles { box-shadow: 0 4px 12px rgba(0,0,0,0.2); backdrop-filter: blur(12px); max-width: min(360px, calc(100vw - 32px)); + min-width: min(220px, calc(100vw - 32px)); + min-height: 34px; + display: inline-flex; + align-items: center; + box-sizing: border-box; } .toast.show { opacity:1; transform: translateX(0); } .toast .toast-checkmark { @@ -9984,6 +9989,17 @@ textarea.memory-add-input { background: color-mix(in srgb, var(--green, #50fa7b) 20%, transparent); border-color: color-mix(in srgb, var(--green, #50fa7b) 35%, transparent); } +.task-run-now-badge { + color: var(--accent, var(--red)); + background: color-mix(in srgb, var(--accent, var(--red)) 16%, transparent); + border-color: color-mix(in srgb, var(--accent, var(--red)) 34%, transparent); +} +.task-card-run-btn { + appearance: none; + height: 20px; + min-height: 0; + box-sizing: border-box; +} .task-status-badge:hover { filter: brightness(1.08) saturate(1.15); } @@ -9995,6 +10011,10 @@ textarea.memory-add-input { background: color-mix(in srgb, var(--green, #50fa7b) 28%, transparent); border-color: color-mix(in srgb, var(--green, #50fa7b) 55%, transparent); } +.task-run-now-badge:hover { + background: color-mix(in srgb, var(--accent, var(--red)) 24%, transparent); + border-color: color-mix(in srgb, var(--accent, var(--red)) 52%, transparent); +} .task-builtin-badge { font-size: 9px; @@ -20518,11 +20538,10 @@ body:not(.welcome-ready) #welcome-screen { margin-bottom: 0; } .task-log-row.expanded .task-log-row-head { margin-bottom: 4px; } -/* Collapsed: body + footer hidden. Expanded: visible. Running/skipped rows - don't expand at all (no body to show). */ -.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-row-body, -.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-row-actions, -.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-prompt { +/* Collapsed: body + footer hidden. Expanded: visible. */ +.task-log-row:not(.expanded):not(.is-skipped) .task-log-row-body, +.task-log-row:not(.expanded):not(.is-skipped) .task-log-row-actions, +.task-log-row:not(.expanded):not(.is-skipped) .task-log-prompt { display: none; } .task-log-name { @@ -20571,6 +20590,26 @@ body:not(.welcome-ready) #welcome-screen { opacity: 0.6; font-variant-numeric: tabular-nums; } +.task-log-stop { + border: 0; + background: transparent; + color: inherit; + opacity: .72; + padding: 0; + margin-left: 6px; + width: 12px; + height: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; + top: -2px; +} +.task-log-stop:hover { + opacity: 1; + color: var(--red, #f87171); +} /* Slim single-line row for skipped (noop) runs — body/actions stripped, font shrunk, opacity dropped. Distinguishes "task ran but had nothing to do" @@ -20718,7 +20757,10 @@ body:not(.welcome-ready) #welcome-screen { margin-top: 4px; } .task-log-open-chat, -.task-log-copy { +.task-log-open-report, +.task-log-copy, +.task-log-clear-cache, +.task-log-run-again { display: inline-flex; align-items: center; gap: 3px; @@ -20734,11 +20776,22 @@ body:not(.welcome-ready) #welcome-screen { line-height: 1.4; } .task-log-open-chat:hover, -.task-log-copy:hover { +.task-log-open-report:hover, +.task-log-copy:hover, +.task-log-clear-cache:hover, +.task-log-run-again:hover { color: var(--fg); border-color: color-mix(in srgb, var(--fg) 30%, transparent); background: color-mix(in srgb, var(--fg) 5%, transparent); } +.task-log-row-actions > .task-log-open-chat, +.task-log-row-actions > .task-log-copy { + margin-left: auto; +} +.task-log-clear-cache svg { + position: relative; + top: 2px; +} /* Activity filter chips — toggle-out model: ON by default (solid), click to toggle OFF (dimmed + strikethrough) to hide that group. */ .tasks-af-chip { @@ -27694,7 +27747,7 @@ body.doc-find-active mark.doc-find-mark.current { } /* Cc toggle and attach button are absolute so they don't steal width from the To input */ .email-field .email-cc-toggle { - position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + position: absolute; right: 6px; top: calc(50% + 4px); transform: translateY(-50%); z-index: 2; } .email-field input { padding-right: 60px; }