merge: reconcile PR 40 with current lab

Integrate lab fff55a78 into PR #40 (cc25d5ba). Lab's modular email
backend/frontend, modular settings, split stylesheets (static/style.css
stays deleted), procfs compatibility, and request-scoped TurnContract
authority win; PR #40's routing classifiers, editor/email/task features,
and style.css changes are ported into lab's module and stylesheet homes.

Integration fixes:
- settings/api.js imports ui.js under its canonical versioned URL
- browser observations keep legacy CAPTCHA/access-block evidence
- artifact turns do not re-trigger broad-web research recovery
- env reference documents PR test-tool variables; page regenerated

PR #40 defects surfaced by lab gates and fixed here:
- web_fetch generic schema drops top-level anyOf (OpenAI contract);
  the compact preview contract still requires url or urls
- get_weather registered as a brokered network read
- new lazy editor modules precached for offline use
- SearXNG pin mirrored into GPU standalone compose files
- image model picker again skips offline endpoints

Tests updated where PR #40 changed behaviour on purpose, and PR tests
moved onto lab's document_source helpers.
This commit is contained in:
Alexandre Teixeira
2026-10-01 05:03:58 +01:00
337 changed files with 90287 additions and 75556 deletions
+5
View File
@@ -1,5 +1,10 @@
# Odysseus UI — Environment Configuration
# Copy this file to .env and fill in your values.
#
# This file stays deliberately short: it is for deployment-level overrides, and
# most runtime configuration belongs in Settings inside the app. For the complete
# list of ODYSSEUS_* variables the code reads, with the default each one falls
# back to, see website/configuration-reference.md (generated from the source).
# ============================================================
# LLM Configuration
+18 -1
View File
@@ -140,7 +140,24 @@ jobs:
cache: pip
- run: pip install -r requirements.txt
if: steps.docs-check.outputs.docs_only != 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
node-version: "20"
cache: npm
- run: npm ci
if: steps.docs-check.outputs.docs_only != 'true'
- run: npx playwright install --with-deps chromium
if: steps.docs-check.outputs.docs_only != 'true'
- run: mkdir -p data # sqlite DB lives at ./data/app.db
if: steps.docs-check.outputs.docs_only != 'true'
- run: python -m pytest -q
- name: Install FFmpeg for media integration tests
if: steps.docs-check.outputs.docs_only != 'true'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg
command -v ffmpeg
ffmpeg -version | head -n 1
- run: python -m pytest -q -rs
if: steps.docs-check.outputs.docs_only != 'true'
+10
View File
@@ -62,6 +62,8 @@ jobs:
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
with:
driver: docker
# Build without pushing so a broken Dockerfile is caught here, and the
# exact image we ship is what gets scanned.
@@ -73,6 +75,9 @@ jobs:
load: true
tags: odysseus:ci
- name: Free build cache before vulnerability database download
run: docker builder prune --all --force
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
@@ -103,6 +108,8 @@ jobs:
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
with:
driver: docker
- name: Build image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@@ -112,6 +119,9 @@ jobs:
load: true
tags: odysseus:ci
- name: Free build cache before vulnerability database download
run: docker builder prune --all --force
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
+3
View File
@@ -26,6 +26,9 @@ secrets.env.*
# Data — all user data stays local
data/
# Per-worktree runtime state written by `odysseus dev` (its own data dir,
# logs and stop handle) — disposable, and never shared between checkouts.
.odysseus-dev/
!services/hwfit/data/
!services/hwfit/data/hf_models.json
logs/
+1 -1
View File
@@ -47,7 +47,7 @@ just composed.
| Service | Image | Purpose | License |
|---|---|---|---|
| [SearXNG](https://github.com/searxng/searxng) | `searxng/searxng:2026.5.31-7159b8aed` (pinned tag; see compose) | Default metasearch backend | AGPL-3.0 |
| [SearXNG](https://github.com/searxng/searxng) | `searxng/searxng:2026.9.25-12f8b6515` (pinned tag; see compose) | Default metasearch backend | AGPL-3.0 |
| [ChromaDB](https://github.com/chroma-core/chroma) | `chromadb/chroma:latest` | Vector store for memory / RAG | Apache-2.0 |
| [ntfy](https://github.com/binwiederhier/ntfy) | `binwiederhier/ntfy` | Push notifications (self-hosted reminders) | Apache-2.0 / GPL-2.0 |
+14 -1
View File
@@ -60,6 +60,19 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
**Untrusted surfaces that must go through this wrapper:** web search results, fetched URLs, emails (read), saved memories, skill text, notes, and any tool output sourced from outside the server. Injecting untrusted content directly into the system role is a security bug.
### Post-external-context tool approval gate — off by default
`src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately.
**It is disabled unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set** (`1`/`true`/`yes`/`on`). The default is off because the gate is conservative enough to interrupt ordinary agent work. That is a deliberate usability trade, and it means a default deployment relies on the wrapper above — not on the gate — to contain injected instructions.
Operators who run the agent against untrusted web or email content with side-effecting tools enabled should turn it on. With the gate off, a successful injection can reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation; with it on, each of those is refused until approved.
Two exemptions apply even when the gate is on, both deliberate:
- Sources in `_CONTROL_PLANE_CONTEXT_SOURCES` (skills, runtime descriptors, the open editor document, the open email, uploaded files) are treated as control-plane metadata and still permit read-only tools.
- A TUI run that advertises a host shell bridge and declares `unattended_mode` exempts the local execution set in `TUI_CLIENT_TOOL_NAMES`. Personal, network and deployment-local tools are never exempted.
## Security Headers
`core/middleware.py:SecurityHeadersMiddleware` sets headers on every response:
@@ -72,7 +85,7 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
These are open, acknowledged, and contributor help is welcome:
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal.
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control, and it is off by default — so on a default deployment this gap is unmitigated beyond the untrusted-context wrapper.
2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this.
+3 -2
View File
@@ -316,6 +316,7 @@ if AUTH_ENABLED:
def _refresh_token_cache():
"""Rebuild the prefix→[(id,hash)] map from the DB."""
global _token_cache
from collections import defaultdict
new_map = defaultdict(list)
db = SessionLocal()
@@ -334,8 +335,8 @@ if AUTH_ENABLED:
new_map[r.token_prefix].append((r.id, r.token_hash, owner_key, scopes))
finally:
db.close()
_token_cache.clear()
_token_cache.update(new_map)
_token_cache = dict(new_map)
app.state._token_cache = _token_cache
app.state._token_cache_dirty = False
# Headers that prove a request was forwarded by a proxy/tunnel (cloudflared,
+13
View File
@@ -36,6 +36,19 @@ IS_APPLE_SILICON = (
)
# ── procfs ──────────────────────────────────────────────────────────────────
# Linux exposes one directory per pid under /proc; macOS and Windows have no
# procfs at all. Any code that walks it must skip the walk rather than raise.
# Kept as a module attribute so both branches stay testable on either kind of
# host.
PROC_ROOT = Path("/proc")
def has_procfs() -> bool:
"""True when the host exposes a procfs pid tree that can be scanned."""
return PROC_ROOT.is_dir()
# ── File permissions ────────────────────────────────────────────────────────
def safe_chmod(path, mode: int) -> bool:
"""``os.chmod`` that is a harmless no-op on Windows.
+1 -1
View File
@@ -133,7 +133,7 @@ services:
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6
entrypoint:
- /bin/sh
- -c
+1 -1
View File
@@ -136,7 +136,7 @@ services:
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6
entrypoint:
- /bin/sh
- -c
+89
View File
@@ -0,0 +1,89 @@
# Ref parity audit
`scripts/ref_parity_audit.py` reports which commits on one git ref left no trace
in another, and which files exist on one and not the other. It is read-only: it
runs `git log`, `git show`, `git diff`, `git grep`, `git ls-tree` and
`git merge-base`, writes nothing to the repository, touches no remote, and does
not import the application package.
## Why it exists
`lab` and the public `dev` line share only the repository's first commit as a
merge base, so `git log lab..dev` lists thousands of commits — nearly all of
which are in fact present on both sides, having arrived under different SHAs. A
plain log tells you nothing about what is actually missing.
The question that matters before `lab` becomes a release is narrower: is there a
fix on the public line that never reached `lab`? This script answers that by
sampling distinctive added lines from each commit and searching the other tree
for them.
## Running it
```bash
git remote add public https://github.com/odysseus-dev/odysseus.git # once
git fetch public dev --no-tags
scripts/ref_parity_audit.py --source public/dev --target lab --since 2026-08-10
```
Roughly 30 seconds for a 100-commit window; it grows linearly, so bound a wide
audit with `--since`. Add `--format json` for a machine-readable report and
`--output PATH` to write it to a file.
| Flag | Effect |
|---|---|
| `--source REF` | The ref whose commits are audited. Required. |
| `--target REF` | The ref searched for traces of them. Required. |
| `--since` / `--until` | Bound the commit range. Both filter **committer** date, which is also the date the report prints. |
| `--traversal linear` | Default. Individual authored commits, merges dropped. Finds a fix that arrived on a side branch. |
| `--traversal first-parent` | One row per merge into the source branch, which reads as one row per merged pull request. |
| `--probes N` | Probe lines sampled per commit, default 4. |
| `--exclude GLOB` | Extra path glob whose lines are not used as probes. Repeatable. |
| `--no-default-excludes` | Drop the built-in vendored / lockfile / binary exclusions. |
| `--top N` | Rows shown per file list, default 50. |
| `--repo PATH` | Repository to run in. Defaults to this checkout. |
## How a verdict is reached
For each commit in `target..source`, the script takes the patch with no context
lines, collects the added lines, drops the ones from vendored code, committed
build output, lockfiles and binaries, and keeps those that are at least 24
characters long and name at least two distinct identifiers. It ranks what is
left by how many distinct identifiers each line carries (length breaks ties),
takes the top `--probes`, and searches the whole target tree for each one with
`git grep --fixed-strings`.
Probes are stripped of leading and trailing whitespace, so a change that was
re-indented on the target still counts as present. The whole target tree is
searched, not the same file, because a ported fix routinely moves.
| Verdict | Meaning |
|---|---|
| **absent** | No probe found anywhere in the target. Treat as a real gap and read the diff. |
| **partial** | Some probes found. **Inconclusive.** A line can be rewritten by a refactor on the target and still be the same change. |
| **present** | Every probe found. The change is almost certainly there in some form. |
| **no-probe** | Nothing to sample: a deletion-only commit, or one touching only excluded paths. No verdict. |
## What is exact and what is a heuristic
**Exact:** the two file-presence lists. They come from `git ls-tree` on both
refs, so a file in "on the source and not the target" is definitely not there.
**Heuristic:** every commit verdict. It samples at most four lines out of a
diff that may be hundreds, and a probe can be absent because the area was
refactored rather than because the change was never made.
The two complement each other in a specific and useful way. A commit that reads
**present** while one of the files it added shows up in the source-only list is
almost always a fix whose production change was reproduced on the target without
its test. The line sampling cannot see that; the presence diff can.
Read the diff before porting anything. The verdicts say where to look, not what
to do.
## Tests
`tests/test_ref_parity_audit.py`. The end-to-end cases build a throwaway
repository with two branches off one root, so the verdicts come from git's own
`grep` and `diff` rather than from a fake.
+10 -4
View File
@@ -3524,11 +3524,17 @@ def setup_chat_routes(
# has an exact routed contract. Adding the whole native
# workspace inventory here reintroduced overlapping PDF
# readers and caused the model to abandon the selected
# OCR operation. Exact operations therefore stay exact;
# ordinary native turns retain warm and workspace tools.
# OCR operation. A task-only turn likewise has an exact
# personal manager; core shell/Web tools are not task
# fallbacks. Other turns retain warm and workspace tools.
extra_tools=(
INTERACTIVE_CORE_TOOLS
if _exact_selected_native_chain
frozenset()
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
_active_turn_capabilities in (
frozenset({"transcription"}), frozenset({"ocr"}),
)
and not _selected_tools
)
else INTERACTIVE_CORE_TOOLS | _warm_tools | (
NATIVE_WORKSPACE_TOOLS | (
{"private_browser"} if _local_browser_render_intent else frozenset()
+55 -9
View File
@@ -19,9 +19,10 @@ Each draft carries:
import json
import logging
import uuid
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.routing import APIRoute
from pydantic import BaseModel
from core.database import EditorDraft, SessionLocal
@@ -76,18 +77,56 @@ def _load_payload(raw: Optional[str]) -> Dict[str, Any]:
return payload if isinstance(payload, dict) else {}
def _draft_too_large() -> HTTPException:
return HTTPException(
413,
f"Editor draft exceeds the {EDITOR_DRAFT_MAX_BYTES // (1024 * 1024)} MB safety limit",
)
def reject_oversized_draft_body(request: Request) -> None:
"""Refuse an oversized draft on declared ``Content-Length``, before the body is read or parsed.
``_dump_payload`` still owns the authoritative byte count, but it only runs
after the request has been parsed and re-serialised. Declaring a body past the
ceiling is enough to reject it early and cheaply. Requests with absent,
malformed, or chunked transfer encoding still hit the authoritative byte count
check further down.
"""
raw_length = request.headers.get("content-length")
if not raw_length:
return
try:
declared = int(raw_length)
except (TypeError, ValueError):
return
if declared > EDITOR_DRAFT_MAX_BYTES:
raise _draft_too_large()
class EditorDraftRoute(APIRoute):
"""Route class that validates declared Content-Length before request body parsing."""
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def custom_route_handler(request: Request) -> Response:
if request.method in ("POST", "PUT", "PATCH"):
reject_oversized_draft_body(request)
return await original_route_handler(request)
return custom_route_handler
def _dump_payload(payload: Dict[str, Any]) -> str:
raw = json.dumps(payload or {}, separators=(",", ":"))
if len(raw.encode("utf-8")) > EDITOR_DRAFT_MAX_BYTES:
raise HTTPException(
413,
f"Editor draft exceeds the {EDITOR_DRAFT_MAX_BYTES // (1024 * 1024)} MB safety limit",
)
raise _draft_too_large()
return raw
def setup_editor_draft_routes() -> APIRouter:
router = APIRouter(tags=["editor-drafts"])
router = APIRouter(tags=["editor-drafts"], route_class=EditorDraftRoute)
@router.get("/api/editor-drafts")
async def list_drafts(request: Request) -> Dict[str, List[Dict[str, Any]]]:
@@ -120,7 +159,10 @@ def setup_editor_draft_routes() -> APIRouter:
db.close()
@router.post("/api/editor-drafts")
async def create_draft(request: Request, body: DraftCreate) -> Dict[str, Any]:
async def create_draft(
request: Request,
body: DraftCreate,
) -> Dict[str, Any]:
user = get_current_user(request)
db = SessionLocal()
try:
@@ -148,7 +190,11 @@ def setup_editor_draft_routes() -> APIRouter:
db.close()
@router.put("/api/editor-drafts/{draft_id}")
async def update_draft(request: Request, draft_id: str, body: DraftUpdate) -> Dict[str, Any]:
async def update_draft(
request: Request,
draft_id: str,
body: DraftUpdate,
) -> Dict[str, Any]:
user = get_current_user(request)
db = SessionLocal()
try:
+8
View File
@@ -0,0 +1,8 @@
"""Email route domain package.
Contains email_routes.py, email_helpers.py and email_pollers.py, migrated
from the flat routes/ directory. Backward-compat shims at
routes/email_routes.py, routes/email_helpers.py and routes/email_pollers.py
replace themselves with these modules, so both import paths resolve to one
object.
"""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -2019
View File
File diff suppressed because it is too large Load Diff
+11 -1759
View File
File diff suppressed because it is too large Load Diff
+11 -7226
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -181,10 +181,17 @@ def setup_mcp_routes(mcp_manager: McpManager):
if transport == "http" and not url:
raise HTTPException(400, "url is required for HTTP transport")
# Parse JSON fields
try:
parsed_args = json.loads(args) if args else []
except json.JSONDecodeError:
# Parse JSON fields. args is not defaulted on a parse failure: an
# unparseable value is silently discarded downstream (stdio spawns
# with an empty argv), so the caller must be told instead.
if args:
try:
parsed_args = json.loads(args)
except json.JSONDecodeError:
raise HTTPException(400, "args must be valid JSON, e.g. [\"-y\", \"pkg\"]")
if not isinstance(parsed_args, list):
raise HTTPException(400, "args must be a JSON array, e.g. [\"-y\", \"pkg\"]")
else:
parsed_args = []
try:
parsed_env = json.loads(env) if env else {}
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""Computed-style snapshot harness for the shipped app CSS cascade.
The app CSS is an ordered multi-file cascade whose rendered result depends
on source order: hundreds of selectors are declared more than once and
``!important`` is used throughout. Any restructuring - extracting a block into
its own file, reordering ``<link>`` tags, moving an ``@media`` rule - can
silently change which declaration wins, and nothing else in the suite would
notice.
This module captures ``getComputedStyle`` for a fixed inventory of elements
across pages, viewports, themes and density modes, hashes the result, and
compares it against a committed baseline. It moves no CSS. It only makes a move
falsifiable.
Usage::
python scripts/css_snapshot.py --check # compare to the baseline
python scripts/css_snapshot.py --write-baseline # re-record it
python scripts/css_snapshot.py --dump before.json # raw values, for diffing
With no ``--origin`` the script serves the repository over loopback on an
ephemeral port for the duration of the run, so it works standalone. Under
pytest the session static server is reused instead.
To see *which property* moved rather than just which element::
python scripts/css_snapshot.py --dump after.json
git stash && python scripts/css_snapshot.py --dump before.json && git stash pop
diff <(python -m json.tool before.json) <(python -m json.tool after.json)
"""
import argparse
import hashlib
import http.server
import json
import os
import shutil
import socketserver
import subprocess
import sys
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SNAPSHOT_DIR = ROOT / "tests" / "css_snapshot"
INVENTORY_PATH = SNAPSHOT_DIR / "inventory.json"
BASELINE_PATH = SNAPSHOT_DIR / "baseline.json"
CAPTURE_SCRIPT = SNAPSHOT_DIR / "capture.mjs"
# A capture is ~70 page loads; on a warm checkout it runs in well under a
# minute, but a cold `npx playwright install` machine can be slow to start
# Chromium the first time.
CAPTURE_TIMEOUT_SECONDS = 900
# Hash prefix length. 16 hex characters is 64 bits - far past any accidental
# collision risk for a few thousand entries, and short enough that the baseline
# stays readable in a diff.
HASH_LENGTH = 16
def load_inventory(path=INVENTORY_PATH):
"""Load the checked-in element inventory."""
return json.loads(Path(path).read_text(encoding="utf-8"))
def load_baseline(path=BASELINE_PATH):
"""Load the committed baseline digest."""
return json.loads(Path(path).read_text(encoding="utf-8"))
def _canonical(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def _hash(value):
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()[:HASH_LENGTH]
def node_available(node="node"):
"""True when the node binary is on PATH."""
return shutil.which(node) is not None
def playwright_available(node="node", cwd=ROOT):
"""True when node can resolve the playwright package from the repo root.
Playwright is a devDependency installed by ``npm ci``; a clean checkout
that has not run it cannot drive a browser at all.
"""
if not node_available(node):
return False
result = subprocess.run(
[node, "-e", "require.resolve('playwright')"],
cwd=str(cwd), capture_output=True, text=True, check=False,
)
return result.returncode == 0
def capture(origin, inventory=None, *, swap_rule=None, variants=None,
node="node", cwd=ROOT, timeout=CAPTURE_TIMEOUT_SECONDS):
"""Drive the browser capture and return ``{"snapshot": ..., "missing": ...}``.
``swap_rule`` swaps the first two top-level declarations of one selector
before the stylesheet reaches the browser. It exists for the harness
self-test: a snapshot that does not move when two conflicting rules trade
places is not evidence of anything.
``variants`` restricts the run to the named variants, for a faster
focused capture.
"""
inventory = inventory or load_inventory()
selected = inventory["variants"]
if variants:
wanted = set(variants)
selected = [v for v in selected if v["name"] in wanted]
unknown = wanted - {v["name"] for v in inventory["variants"]}
if unknown:
raise ValueError(f"unknown variants: {sorted(unknown)}")
job = {
"origin": origin.rstrip("/"),
"properties": inventory["properties"],
"variants": selected,
"pages": inventory["pages"],
"swapRule": swap_rule,
}
result = subprocess.run(
[node, str(CAPTURE_SCRIPT)],
input=json.dumps(job), cwd=str(cwd),
capture_output=True, text=True, check=False, timeout=timeout,
)
if result.returncode != 0:
raise RuntimeError(f"css snapshot capture failed:\n{result.stderr.strip()}")
return json.loads(result.stdout)
def summarize(snapshot):
"""Reduce a raw capture to the committed digest shape.
Two orthogonal projections are stored rather than one hash per
(element, variant) pair: hashing every pair would commit ~5,000 lines that
nobody reads, while a single global digest would only ever say "something
moved". Per-element and per-variant hashes localise a failure from both
directions - which element drifted, and in which variant - for a file small
enough to review.
"""
elements = {}
variants = {}
for page, per_variant in snapshot.items():
element_values = {}
variants[page] = {}
for variant, measured in per_variant.items():
variants[page][variant] = _hash(measured)
for key, values in measured.items():
element_values.setdefault(key, {})[variant] = values
elements[page] = {key: _hash(values) for key, values in element_values.items()}
return {
"digest": _hash(snapshot),
"elements": elements,
"variants": variants,
}
def compare(baseline, current):
"""Return the drift between a committed baseline and a fresh summary."""
drift = {"digest_changed": baseline.get("digest") != current["digest"],
"elements": [], "variants": []}
for section in ("elements", "variants"):
old = baseline.get(section, {})
new = current.get(section, {})
for page in sorted(set(old) | set(new)):
old_page = old.get(page, {})
new_page = new.get(page, {})
for key in sorted(set(old_page) | set(new_page)):
if old_page.get(key) != new_page.get(key):
drift[section].append(f"{page}/{key}")
return drift
def serve_repository(root=ROOT):
"""Serve the repository over loopback on an ephemeral port.
Mirrors the browser-test static server in ``tests/conftest.py`` so the CLI
can run outside pytest. Returns ``(origin, shutdown)``.
"""
root = Path(root).resolve()
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(root), **kwargs)
def log_message(self, fmt, *args):
pass
def guess_type(self, path):
if path.endswith(".js") or path.endswith(".mjs"):
return "application/javascript"
if path.endswith(".css"):
return "text/css"
return super().guess_type(path)
class Server(socketserver.TCPServer):
allow_reuse_address = True
server = Server(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
def shutdown():
server.shutdown()
server.server_close()
return f"http://127.0.0.1:{server.server_address[1]}", shutdown
def _describe(drift, limit=25):
lines = []
for section in ("elements", "variants"):
items = drift[section]
if not items:
continue
shown = items[:limit]
suffix = f" (+{len(items) - limit} more)" if len(items) > limit else ""
lines.append(f" {section} that moved ({len(items)}): {', '.join(shown)}{suffix}")
return "\n".join(lines) or " (no per-element drift; the digest itself changed)"
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--origin", help="static server origin to capture against; "
"one is started on an ephemeral port when omitted")
parser.add_argument("--write-baseline", action="store_true",
help=f"re-record {BASELINE_PATH.relative_to(ROOT)}")
parser.add_argument("--check", action="store_true",
help="compare against the committed baseline (default)")
parser.add_argument("--dump", metavar="PATH",
help="write the raw computed values, for property-level diffing")
parser.add_argument("--swap-rule", metavar="SELECTOR",
help="swap the first two top-level declarations of SELECTOR "
"before capturing (harness self-test)")
parser.add_argument("--variants", help="comma-separated variant names to restrict the run to")
parser.add_argument("--node", default="node", help="node binary to use")
args = parser.parse_args(argv)
if not playwright_available(args.node):
parser.error("node with the playwright package is required; run `npm ci` first")
variants = [v.strip() for v in args.variants.split(",")] if args.variants else None
shutdown = None
origin = args.origin or os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN")
if not origin:
origin, shutdown = serve_repository()
try:
captured = capture(origin, swap_rule=args.swap_rule, variants=variants, node=args.node)
finally:
if shutdown:
shutdown()
if captured["missing"]:
print("inventory entries that matched no element:", file=sys.stderr)
for scope, keys in sorted(captured["missing"].items()):
print(f" {scope}: {', '.join(keys)}", file=sys.stderr)
summary = summarize(captured["snapshot"])
if args.dump:
Path(args.dump).write_text(json.dumps(captured["snapshot"], indent=1, sort_keys=True) + "\n",
encoding="utf-8")
print(f"raw values written to {args.dump}")
if args.write_baseline:
if variants or args.swap_rule:
parser.error("--write-baseline needs a full, unmutated capture: "
"drop --variants and --swap-rule")
BASELINE_PATH.write_text(json.dumps(summary, indent=1, sort_keys=True) + "\n",
encoding="utf-8")
print(f"baseline written: digest {summary['digest']}")
return 0
baseline = load_baseline()
drift = compare(baseline, summary)
if not drift["digest_changed"] and not drift["elements"] and not drift["variants"]:
print(f"computed styles match the baseline (digest {summary['digest']})")
return 0
print(f"computed styles moved: baseline {baseline.get('digest')} -> {summary['digest']}")
print(_describe(drift))
return 1
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+853
View File
@@ -0,0 +1,853 @@
#!/usr/bin/env python3
"""odysseus-dev — boot the checkout you are standing in, isolated from every other one.
`start-macos.sh` is the single-instance launcher: it owns the Homebrew
deps, the venv, and the production-shaped boot. It deliberately shares
whatever is already listening — an open ChromaDB port is a resource it
adopts. That is right for one instance and wrong for N worktrees, where
adopting a port means writing into another checkout's vector store.
This tool is the sibling that owns isolation instead:
- ports are derived from the worktree path, so two checkouts never
pick the same ones and the same checkout always picks its own;
- a ChromaDB we did not start is never adopted — we start our own on
our own port against our own data dir, or fall closed to keyword
mode and say so;
- the data dir, the database and the browser-MCP cache all live under
`.odysseus-dev/`, so a dev boot leaves `data/` — what a normal launch
of this checkout owns — untouched;
- readiness is `/api/ready` (database, writable data dir, storage
metadata), never a TCP accept and never `/api/health`, which is
liveness only;
- the app runs detached with durable logs and a recorded stop handle,
so closing the terminal does not decide the instance's lifetime.
odysseus dev up # boot this worktree, print URL + stop handle
odysseus dev up --from-pr 42 # fetch PR 42 into a worktree and boot that
odysseus dev status # what is running here (JSON)
odysseus dev down # stop what `up` started here
odysseus dev ports # the derived port set (JSON)
odysseus dev env # shell exports for running tests in this worktree
Every subcommand acts on the checkout containing the current working
directory, so a single copy on $PATH serves every worktree.
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
from cli import quiet_logs, emit, fail, common_parser, run # noqa: E402
quiet_logs()
import hashlib # noqa: E402
import json # noqa: E402
import signal # noqa: E402
import socket # noqa: E402
import subprocess # noqa: E402
import time # noqa: E402
import urllib.error # noqa: E402
import urllib.request # noqa: E402
from pathlib import Path # noqa: E402
# Everything this tool writes lives under one directory inside the
# worktree, next to but never inside `data/` — a dev boot must not be
# able to corrupt the data dir a normal `start-macos.sh` run owns.
DEV_DIR_NAME = ".odysseus-dev"
STATE_FILE_NAME = "run.json"
# Files that identify a checkout root, so `odysseus dev` from any
# subdirectory finds the worktree it belongs to.
ROOT_MARKERS = ("app.py", "setup.py", "requirements.txt")
# Port block derivation. Three consecutive ports per worktree (app,
# ChromaDB, test static server) starting at 7200; the last block ends at
# 7799. The range is chosen to exclude every port the project already
# means something by, so a derived port can never collide with a normal
# launch on the same machine.
PORT_BLOCK_BASE = 7200
PORT_BLOCK_COUNT = 200
PORTS_PER_BLOCK = 3
# Ports this tool refuses to use even when asked explicitly, with the
# reason each one is spoken for.
RESERVED_PORTS = {
7000: "the historical app default (and macOS AirPlay Receiver)",
7011: "the app's own default bind and the compose APP_PORT",
7860: "start-macos.sh's default, i.e. a normal launch of this app",
8100: "the default CHROMADB_PORT, i.e. someone else's vector store",
}
READY_PATH = "/api/ready"
HEALTH_PATH = "/api/health"
LOGIN_PATH = "/api/auth/login"
SESSION_COOKIE = "odysseus_session"
DEFAULT_READY_TIMEOUT = 180
STOP_GRACE_SECONDS = 10
# `/api/ready` is not in app.py's AUTH_EXEMPT_EXACT set, so readiness is
# only observable with a session. The launcher therefore owns the dev
# admin account: it generates the password once, hands it to setup.py,
# keeps it here, and prints it — otherwise a generated password scrolls
# past on first boot and the instance is unusable afterwards.
CREDENTIALS_FILE_NAME = "admin.json"
VENV_FILE_NAME = "venv-path"
DEV_ADMIN_USER = "admin"
# --------------------------------------------------------------------------
# Locating the worktree
# --------------------------------------------------------------------------
def find_repo_root(start):
"""Return the checkout root at or above `start`, or None.
Resolved from the working directory rather than from this file, so a
symlink on $PATH still boots the worktree the user is standing in.
"""
current = Path(start).resolve()
for candidate in [current, *current.parents]:
if all((candidate / marker).exists() for marker in ROOT_MARKERS):
return candidate
return None
def dev_dir(root):
return Path(root) / DEV_DIR_NAME
def state_path(root):
return dev_dir(root) / STATE_FILE_NAME
# --------------------------------------------------------------------------
# Ports
# --------------------------------------------------------------------------
def derive_ports(root):
"""Map a worktree path to its own block of three ports.
Deterministic: the same checkout gets the same ports on every run, so
a bookmarked URL keeps working, and two checkouts only collide if
their paths hash into the same block — which `up` detects and refuses
rather than papers over.
"""
digest = hashlib.blake2s(str(Path(root).resolve()).encode("utf-8"), digest_size=8).digest()
block = int.from_bytes(digest, "big") % PORT_BLOCK_COUNT
base = PORT_BLOCK_BASE + block * PORTS_PER_BLOCK
return {"app": base, "chroma": base + 1, "test_static": base + 2}
def reserved_reason(port):
"""Return why `port` is off limits, or None if it is usable."""
return RESERVED_PORTS.get(int(port))
def port_bound(port, host="127.0.0.1", timeout=0.4):
"""True if something already accepts connections on host:port."""
try:
with socket.create_connection((host, int(port)), timeout=timeout):
return True
except OSError:
return False
def unused_port():
"""Ask the OS for a free port and release it immediately.
Used only to point CHROMADB_PORT at something that will refuse the
connection, which is how the app falls back to keyword mode.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
# --------------------------------------------------------------------------
# Refusing to boot where a real instance lives
# --------------------------------------------------------------------------
def service_unit_dirs():
home = Path.home()
if sys.platform == "darwin":
return [
home / "Library" / "LaunchAgents",
Path("/Library/LaunchAgents"),
Path("/Library/LaunchDaemons"),
]
return [
home / ".config" / "systemd" / "user",
Path("/etc/systemd/system"),
Path("/usr/lib/systemd/system"),
]
def managed_by_service(root, unit_dirs=None):
"""Return the unit file naming a path inside `root`, or None.
A checkout wired into launchd or systemd is somebody's running
instance: booting a second process out of it would share its source
tree and, on the first mistake, its data. We refuse rather than trust
the user to remember which directory this is. The match is on the
path, so a unit pointing anywhere inside the checkout counts.
"""
needle = str(Path(root).resolve())
for directory in unit_dirs if unit_dirs is not None else service_unit_dirs():
try:
entries = sorted(Path(directory).iterdir())
except OSError:
continue
for entry in entries:
if entry.suffix not in (".plist", ".service"):
continue
try:
text = entry.read_text(errors="ignore")
except OSError:
continue
if needle in text:
return str(entry)
return None
# --------------------------------------------------------------------------
# Process ownership
# --------------------------------------------------------------------------
def pid_command(pid):
"""Return the full command line of `pid`, or "" if it is not ours to see."""
try:
result = subprocess.run(
["ps", "-o", "command=", "-p", str(int(pid))],
capture_output=True, text=True, timeout=5, check=False,
)
except (OSError, subprocess.SubprocessError, ValueError):
return ""
return result.stdout.strip() if result.returncode == 0 else ""
def pid_is_ours(pid, fingerprints):
"""True only when `pid` is alive AND its command line still shows every
fingerprint we recorded when we started it.
A pid alone proves nothing — the number is reused. Everything that
kills or adopts a process goes through here.
"""
if not pid:
return False
command = pid_command(pid)
if not command:
return False
return all(str(mark) in command for mark in fingerprints)
def pid_alive(pid):
try:
os.kill(int(pid), 0)
except (OSError, TypeError, ValueError):
return False
return True
def credentials(root):
"""Return this worktree's dev admin account, generating it once.
Stored outside the data dir so `down`, a wiped database, or a fresh
`up` all keep the same login.
"""
path = dev_dir(root) / CREDENTIALS_FILE_NAME
try:
with open(path, encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
pass
import secrets
account = {"username": DEV_ADMIN_USER, "password": secrets.token_urlsafe(18)}
dev_dir(root).mkdir(parents=True, exist_ok=True)
with open(os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600), "w",
encoding="utf-8") as handle:
json.dump(account, handle, indent=2)
return account
def read_state(root):
try:
with open(state_path(root), encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return {}
def write_state(root, state):
dev_dir(root).mkdir(parents=True, exist_ok=True)
with open(state_path(root), "w", encoding="utf-8") as handle:
json.dump(state, handle, indent=2)
def running_app(state):
"""Return the recorded app entry if that exact process is still alive."""
app = (state or {}).get("app") or {}
if pid_is_ours(app.get("pid"), app.get("fingerprints") or []):
return app
return None
# --------------------------------------------------------------------------
# ChromaDB
# --------------------------------------------------------------------------
def start_chroma(venv_python, port, chroma_path, log_path):
"""Start our own ChromaDB, or explain why we are going without one.
Returns (entry_or_None, note). Adopting a foreign server is not one of
the outcomes: the caller has already established that the port is free.
"""
binary = Path(venv_python).parent / "chroma"
if not binary.exists():
return None, (
"keyword-only mode: no `chroma` binary in this venv "
"(requirements.txt pins chromadb-client, the HTTP client). "
f"Install the server with `{venv_python} -m pip install chromadb` to enable vectors."
)
chroma_path.mkdir(parents=True, exist_ok=True)
command = [
str(binary), "run",
"--host", "127.0.0.1",
"--port", str(port),
"--path", str(chroma_path),
]
with open(log_path, "ab") as log:
process = subprocess.Popen(
command, stdout=log, stderr=subprocess.STDOUT,
start_new_session=True, cwd=str(chroma_path.parent),
)
entry = {
"pid": process.pid,
"port": port,
"path": str(chroma_path),
"log": str(log_path),
# The data path is the identity: it is unique to this worktree
# and appears in the command line whichever way ps resolves the
# console script.
"fingerprints": ["chroma", str(chroma_path)],
}
return entry, f"own server on 127.0.0.1:{port} against {chroma_path}"
# --------------------------------------------------------------------------
# Readiness
# --------------------------------------------------------------------------
def http_json(port, path, payload=None, cookie=None, timeout=5.0):
"""One request against the local instance. Returns (status, body)."""
url = f"http://127.0.0.1:{port}{path}"
data = json.dumps(payload).encode("utf-8") if payload is not None else None
headers = {"Content-Type": "application/json"} if data else {}
if cookie:
headers["Cookie"] = f"{SESSION_COOKIE}={cookie}"
request = urllib.request.Request(url, data=data, headers=headers)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.status, _decode(response.read()), response
except urllib.error.HTTPError as exc:
return exc.code, _decode(exc.read()), exc
except (OSError, ValueError) as exc:
return 0, {"error": str(exc)}, None
def _decode(raw):
try:
return json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return {}
def login(port, account):
"""Return a session cookie for the dev admin, or None."""
status, _, response = http_json(port, LOGIN_PATH, payload={
"username": account["username"], "password": account["password"],
})
if status != 200 or response is None:
return None
for header in response.headers.get_all("Set-Cookie") or []:
if header.startswith(f"{SESSION_COOKIE}="):
return header.split(";", 1)[0].split("=", 1)[1]
return None
def probe_ready(port, cookie=None, timeout=5.0):
"""GET /api/ready once. Returns (ready, payload).
/api/health only proves the process is alive. /api/ready is the one
that checks the database, a writable data dir and storage metadata,
and it answers 503 until all three hold — which is why a TCP accept
is not what this tool waits for.
"""
status, body, _ = http_json(port, READY_PATH, cookie=cookie, timeout=timeout)
body = dict(body or {})
body.setdefault("status", status)
return bool(body.get("ready")), body
def wait_ready(port, process, timeout, log_path, account):
"""Wait for liveness, authenticate, then wait for real readiness."""
deadline = time.monotonic() + timeout
def alive():
if process is not None and process.poll() is not None:
fail(
f"the app exited with code {process.returncode} before becoming ready.\n"
f" last lines of {log_path}:\n{tail(log_path, 20)}"
)
while time.monotonic() < deadline:
alive()
if http_json(port, HEALTH_PATH, timeout=2.0)[0] == 200:
break
time.sleep(1)
# One login, not one per poll: the login route is rate limited.
cookie, last = None, {}
while time.monotonic() < deadline and cookie is None:
alive()
cookie = login(port, account)
if cookie is None:
time.sleep(3)
if cookie is None:
fail(
f"could not log in as {account['username']} to read {READY_PATH}.\n"
f" The recorded credentials may not match this data dir. Remove "
f"{Path(log_path).parent.parent / CREDENTIALS_FILE_NAME} and the data dir "
f"to start clean.\n"
f" The app is running; stop it with `odysseus dev down`."
)
while time.monotonic() < deadline:
alive()
ready, last = probe_ready(port, cookie=cookie)
if ready:
return last
time.sleep(1)
fail(
f"{READY_PATH} did not report ready within {timeout}s.\n"
f" last response: {json.dumps(last, default=str)[:400]}\n"
f" the app is still running; logs: {log_path}\n"
f" stop it with `odysseus dev down`"
)
def tail(path, lines):
try:
with open(path, encoding="utf-8", errors="replace") as handle:
return "".join(f" {line}" for line in handle.readlines()[-lines:])
except OSError:
return " (no log)"
# --------------------------------------------------------------------------
# git helpers for --from-pr
# --------------------------------------------------------------------------
def git(root, *args, check=True):
result = subprocess.run(
["git", "-C", str(root), *args],
capture_output=True, text=True, check=False,
)
if check and result.returncode != 0:
fail(f"git {' '.join(args)} failed: {result.stderr.strip()}")
return result.stdout.strip()
def worktree_for_pr(root, number, remote):
"""Fetch a pull request head into its own worktree and return its path.
`pull/<n>/head` is served by the repository the PR targets, so this
works for forks without knowing anything about the fork layout.
"""
target = Path(root).resolve().parent / f"{Path(root).resolve().name}-pr{number}"
if target.exists():
sys.stdout.write(f" worktree for PR {number} already exists at {target}\n")
return target
git(root, "fetch", remote, f"pull/{number}/head")
head = git(root, "rev-parse", "FETCH_HEAD")
git(root, "worktree", "add", "--detach", str(target), head)
sys.stdout.write(f" PR {number} checked out at {target} ({head[:8]})\n")
return target
# --------------------------------------------------------------------------
# Commands
# --------------------------------------------------------------------------
def resolve_root(args):
root = find_repo_root(Path.cwd())
if root is None:
fail(
"not inside an Odysseus checkout "
f"(looked for {', '.join(ROOT_MARKERS)} from {Path.cwd()} upwards)",
code=2,
)
return root
def resolve_ports(root, args):
ports = derive_ports(root)
for name, override in (("app", getattr(args, "port", None)),
("chroma", getattr(args, "chroma_port", None))):
if override:
ports[name] = int(override)
for name, port in ports.items():
reason = reserved_reason(port)
if reason:
fail(f"port {port} is {reason}; refusing to use it as the {name} port")
return ports
def remembered_venv(root):
"""The venv a previous `up` borrowed for this worktree, if any."""
try:
return Path(dev_dir(root).joinpath(VENV_FILE_NAME).read_text(encoding="utf-8").strip())
except OSError:
return None
def remember_venv(root, venv_root):
dev_dir(root).mkdir(parents=True, exist_ok=True)
dev_dir(root).joinpath(VENV_FILE_NAME).write_text(str(venv_root), encoding="utf-8")
def resolve_venv(root, args):
"""Pick the interpreter to run the app with. This tool never builds a
venv — `--venv` pointing at a sibling worktree's environment is what
makes booting a PR take seconds rather than minutes, and the choice is
remembered so the next `up` in that worktree does not need the flag."""
candidates = [
Path(args.venv).expanduser().resolve() if args.venv else None,
Path(root) / "venv",
remembered_venv(root),
]
for candidate in candidates:
if candidate and (candidate / "bin" / "python").exists():
return candidate / "bin" / "python"
if args.venv:
fail(f"no interpreter at {Path(args.venv).expanduser().resolve() / 'bin' / 'python'}")
fail(
f"no venv at {Path(root) / 'venv'}.\n"
f" build one with ./start-macos.sh, or reuse another worktree's "
f"with --venv /path/to/worktree/venv"
)
def refuse_if_taken(root, args, ports, state):
"""Stop before anything is started if this worktree cannot own the boot."""
unit = managed_by_service(root)
if unit:
fail(
f"{root} is run as a service by {unit}.\n"
f" That is a real instance, not a scratch worktree. Boot a separate "
f"checkout instead:\n"
f" git worktree add ../odysseus-dev <ref> && cd ../odysseus-dev"
)
if port_bound(ports["app"]):
fail(
f"port {ports['app']} is already in use by a process we do not own.\n"
f" This worktree derives that port from its path, so something else "
f"took it.\n"
f" Re-run with --port <n> to pick another."
)
def resolve_chroma(args, state, ports, venv_python, data_dir, log_dir):
"""Decide what this worktree talks to for vectors.
Returns (state_entry, port, note). The one outcome this never
produces is a port somebody else is serving: the whole tool exists
because `start-macos.sh` treats that as a resource to adopt.
"""
if args.no_chroma:
# Point at a port nothing is listening on rather than at the
# derived one, which may be exactly the foreign server we are
# refusing to touch. Connection refused is what makes the app
# fall back to keyword search.
return None, unused_port(), "disabled by --no-chroma"
if port_bound(ports["chroma"]):
ours = (state or {}).get("chroma") or {}
if pid_is_ours(ours.get("pid"), ours.get("fingerprints") or []):
return ours, ours["port"], f"reusing the server we started earlier on {ours['port']}"
fail(
f"port {ports['chroma']} is serving a ChromaDB this worktree did not start.\n"
f" Adopting it would read and write another checkout's vectors, so we "
f"will not.\n"
f" Re-run with --chroma-port <n>, or with --no-chroma to run in "
f"keyword-only mode."
)
entry, note = start_chroma(
venv_python, ports["chroma"], data_dir / "chroma", log_dir / "chroma.log"
)
# If we could not start one, point the app at a port nothing is on
# rather than at our derived one: otherwise a ChromaDB that binds
# that port later would be adopted by a running app, which is the
# exact failure this tool exists to prevent.
return entry, (ports["chroma"] if entry else unused_port()), note
def boot_environment(account, ports, chroma_port, data_dir):
"""The environment that makes the child process this worktree's own."""
env = dict(os.environ)
env.update({
"ODYSSEUS_ADMIN_USER": account["username"],
"ODYSSEUS_ADMIN_PASSWORD": account["password"],
"APP_PORT": str(ports["app"]),
"APP_BIND": "127.0.0.1",
"ODYSSEUS_DATA_DIR": str(data_dir),
"DATABASE_URL": f"sqlite:///{data_dir / 'app.db'}",
# src/builtin_mcp.py derives this cache from a literal "data"
# under the app root rather than from DATA_DIR, so without an
# explicit value a dev boot would write into the checkout's
# data/ after all. Pointing it at our own dir keeps the
# isolation claim true.
"ODYSSEUS_BROWSER_MCP_CACHE": str(data_dir / "playwright-mcp-cache"),
"CHROMADB_HOST": "127.0.0.1",
"CHROMADB_PORT": str(chroma_port),
"ODYSSEUS_TEST_STATIC_PORT": str(ports["test_static"]),
"ODYSSEUS_NO_OPEN": "1",
"ODYSSEUS_SKIP_RUN_HINT": "1",
"ODYSSEUS_SKIP_ADMIN_PROMPT": "1",
})
return env
def run_setup(root, venv_python, env, data_dir, log_dir):
"""Create the data dir, database and admin account. Idempotent."""
sys.stdout.write(f" preparing {data_dir} (setup.py is idempotent)\n")
setup = subprocess.run(
[str(venv_python), "setup.py"], cwd=str(root), env=env,
capture_output=True, text=True, stdin=subprocess.DEVNULL, check=False,
)
log = log_dir / "setup.log"
with open(log, "w", encoding="utf-8") as handle:
handle.write(setup.stdout + setup.stderr)
if setup.returncode != 0:
fail(f"setup.py failed; see {log}\n{tail(log, 15)}")
def borrow_venv_for_pr(root, args):
"""A fresh PR worktree has no venv; the one we came from will do."""
if args.venv or (root / "venv" / "bin" / "python").exists():
return
source_venv = find_repo_root(Path.cwd()) / "venv"
if (source_venv / "bin" / "python").exists():
args.venv = str(source_venv)
sys.stdout.write(f" reusing {source_venv} (the PR worktree has none)\n")
def cmd_up(args):
root = resolve_root(args)
if args.from_pr:
root = worktree_for_pr(root, args.from_pr, args.remote)
borrow_venv_for_pr(root, args)
state = read_state(root)
already = running_app(state)
if already:
sys.stdout.write(
f"already up: http://127.0.0.1:{already['port']} (pid {already['pid']})\n"
f"stop it with `odysseus dev down`, or re-run after that to restart.\n"
)
return
ports = resolve_ports(root, args)
refuse_if_taken(root, args, ports, state)
venv_python = resolve_venv(root, args)
remember_venv(root, venv_python.parent.parent)
data_dir = dev_dir(root) / "data"
log_dir = dev_dir(root) / "logs"
data_dir.mkdir(parents=True, exist_ok=True)
log_dir.mkdir(parents=True, exist_ok=True)
app_log = log_dir / "app.log"
chroma_entry, chroma_port, chroma_note = resolve_chroma(
args, state, ports, venv_python, data_dir, log_dir
)
account = credentials(root)
env = boot_environment(account, ports, chroma_port, data_dir)
run_setup(root, venv_python, env, data_dir, log_dir)
command = [
str(venv_python), "-m", "uvicorn", "app:app",
"--host", "127.0.0.1", "--port", str(ports["app"]),
]
if args.foreground:
sys.stdout.write(f" starting in the foreground on http://127.0.0.1:{ports['app']}\n")
os.execve(str(venv_python), command, env)
with open(app_log, "ab") as log:
process = subprocess.Popen(
command, cwd=str(root), env=env, stdout=log, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, start_new_session=True,
)
state = {
"root": str(root),
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"commit": git(root, "rev-parse", "--short", "HEAD", check=False),
"branch": git(root, "rev-parse", "--abbrev-ref", "HEAD", check=False),
"venv": str(Path(venv_python).parent.parent),
"data_dir": str(data_dir),
"ports": ports,
"app": {
"pid": process.pid,
"port": ports["app"],
"log": str(app_log),
# The interpreter path is not one of these on purpose: macOS
# reports the framework binary a venv symlinks to, not the
# venv path we launched. The port is derived per worktree, so
# it is the part that actually identifies this instance.
"fingerprints": ["uvicorn", "app:app", f"--port {ports['app']}"],
},
"chroma": chroma_entry,
}
write_state(root, state)
sys.stdout.write(f" waiting for {READY_PATH} (up to {args.timeout}s)\n")
report = wait_ready(ports["app"], process, args.timeout, app_log, account)
sys.stdout.write(
f"\nOdysseus is up — this worktree only.\n\n"
f" URL http://127.0.0.1:{ports['app']}\n"
f" Login {account['username']} / {account['password']}\n"
f" Worktree {root} ({state['branch']} @ {state['commit']})\n"
f" Data dir {data_dir}\n"
f" ChromaDB {chroma_note}\n"
f" Test port {ports['test_static']} (ODYSSEUS_TEST_STATIC_PORT; see `odysseus dev env`)\n"
f" Logs {app_log}\n"
f" Ready {json.dumps({k: v.get('ok') for k, v in report.get('checks', {}).items()})}\n"
f" Stop with odysseus dev down\n"
)
def cmd_down(args):
root = resolve_root(args)
state = read_state(root)
stopped, unclaimed = [], []
for name in ("app", "chroma"):
entry = (state or {}).get(name) or {}
pid = entry.get("pid")
if not pid_is_ours(pid, entry.get("fingerprints") or []):
if pid_alive(pid):
# Alive but no longer recognisable: signalling it would be
# signalling a stranger. Say so and keep the record.
unclaimed.append(f"{name} (pid {pid})")
continue
os.kill(pid, signal.SIGTERM)
deadline = time.monotonic() + STOP_GRACE_SECONDS
while time.monotonic() < deadline and pid_is_ours(pid, entry.get("fingerprints") or []):
time.sleep(0.2)
if pid_is_ours(pid, entry.get("fingerprints") or []):
os.kill(pid, signal.SIGKILL)
stopped.append(f"{name} (pid {pid})")
if stopped:
sys.stdout.write(f"stopped {', '.join(stopped)}.\n")
elif not unclaimed:
sys.stdout.write("nothing this worktree started is still running.\n")
if unclaimed:
sys.stdout.write(
f"left alone: {', '.join(unclaimed)} — still alive but no longer matching "
f"what we recorded. Check it before killing it; {state_path(root)} is kept.\n"
)
return
try:
state_path(root).unlink()
except OSError:
pass
def cmd_status(args):
root = resolve_root(args)
state = read_state(root)
app = running_app(state)
chroma = (state or {}).get("chroma") or {}
ready = False
if app:
ready = probe_ready(app["port"], cookie=login(app["port"], credentials(root)))[0]
emit({
"root": str(root),
"running": bool(app),
"url": f"http://127.0.0.1:{app['port']}" if app else None,
"ready": ready,
"chroma_running": pid_is_ours(chroma.get("pid"), chroma.get("fingerprints") or []),
"ports": (state or {}).get("ports") or derive_ports(root),
"state_file": str(state_path(root)),
}, args)
def cmd_ports(args):
root = resolve_root(args)
ports = derive_ports(root)
emit({
"root": str(root),
"ports": ports,
"in_use": {name: port_bound(port) for name, port in ports.items()},
}, args)
def cmd_env(args):
"""Print the isolated environment as shell exports, so a test run in
this worktree uses the same ports and data dir the app does."""
root = resolve_root(args)
ports = derive_ports(root)
data = dev_dir(root) / "data"
for key, value in (
("APP_PORT", ports["app"]),
("ODYSSEUS_TEST_STATIC_PORT", ports["test_static"]),
("CHROMADB_PORT", ports["chroma"]),
("ODYSSEUS_DATA_DIR", data),
("DATABASE_URL", f"sqlite:///{data / 'app.db'}"),
):
sys.stdout.write(f"export {key}={value}\n")
def build_parser():
parser = common_parser("odysseus-dev", "Boot this worktree in isolation.")
common = parser._common_parents[0]
sub = parser.add_subparsers(dest="cmd")
up = sub.add_parser("up", parents=[common], help="boot this worktree")
up.add_argument("--port", type=int, help="override the derived app port")
up.add_argument("--chroma-port", type=int, help="override the derived ChromaDB port")
up.add_argument("--no-chroma", action="store_true",
help="run without vectors (keyword mode) instead of starting a server")
up.add_argument("--venv", help="use this venv instead of ./venv (e.g. a sibling worktree's)")
up.add_argument("--from-pr", type=int, metavar="N",
help="fetch pull request N into its own worktree and boot that")
up.add_argument("--remote", default="origin", help="remote to fetch the PR from")
up.add_argument("--foreground", action="store_true",
help="run uvicorn in this terminal instead of detaching")
up.add_argument("--timeout", type=int, default=DEFAULT_READY_TIMEOUT,
help=f"seconds to wait for {READY_PATH} (default: {DEFAULT_READY_TIMEOUT})")
up.set_defaults(func=cmd_up)
down = sub.add_parser("down", parents=[common], help="stop what `up` started here")
down.set_defaults(func=cmd_down)
status = sub.add_parser("status", parents=[common], help="what is running in this worktree")
status.set_defaults(func=cmd_status)
ports = sub.add_parser("ports", parents=[common], help="the derived port set")
ports.set_defaults(func=cmd_ports)
env = sub.add_parser("env", parents=[common], help="shell exports for this worktree")
env.set_defaults(func=cmd_env)
parser.set_defaults(func=lambda args: parser.print_help())
return parser
if __name__ == "__main__":
sys.exit(run(build_parser()))
+1 -1
View File
@@ -2,7 +2,7 @@
"""odysseus-mail — Unix-style command-line wrapper around the email
backend that powers the web UI.
Calls the same helpers `routes/email_helpers.py` exports, so a request
Calls the same helpers `routes/email/email_helpers.py` exports, so a request
issued from the shell hits IMAP/SMTP through the same connection pool
and the same parsing pipeline as the HTTP routes. State is shared via
`data/app.db` and `data/.app_key` (passwords decrypt automatically).
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""odysseus-smoke — boot this worktree and drive every advertised feature area once.
The decomposition work has two safety nets and neither one covers the
product: the checkpoint benchmark measures the agent runtime, and the
computed-style snapshot pins the CSS. Nothing checked that Notes,
Calendar, Documents, Email, Memory, Cookbook or Settings still worked
after a route package moved or a 17,000-line module was split. This is
that check, and it is deliberately shallow: one scenario per area,
asserting a user-visible outcome rather than an HTTP 200.
It owns no instance logic. `odysseus dev` already isolates the ports,
the data dir and ChromaDB per worktree, so this boots through it, hands
the details to pytest in the environment, and stops what it started.
odysseus smoke # boot, run every area, stop again
odysseus smoke --keep-up # leave the instance running afterwards
odysseus smoke --no-boot # drive whatever is already up here
odysseus smoke --restart # stop a running instance and boot fresh
odysseus smoke --areas # print the coverage table without running
odysseus smoke -- -k notes # everything after -- goes to pytest
The report is a per-area table, printed by the suite itself, listing the
areas it does not cover next to the ones it does. An area with no
scenario shows up as NOT RUN rather than going missing.
"""
from __future__ import annotations
import importlib.machinery
import importlib.util
import os
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
from cli import quiet_logs, fail, common_parser, run # noqa: E402
quiet_logs()
SCRIPTS_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPTS_DIR.parent
# The launcher this tool delegates every instance decision to.
DEV_SCRIPT = "odysseus-dev"
# What pytest is pointed at, relative to the checkout root.
SMOKE_SUITE = "tests/smoke"
# Email is the one area with no reachable real backend, and the repo
# already has a deterministic path for it. Turning it on is the reason
# this tool owns the boot rather than leaving it to the caller: the flag
# is read inside the app's process, so it has to be in the environment
# the app is started with.
EMAIL_FIXTURE_ENV = "ODYSSEUS_EMAIL_FIXTURE"
def load_dev():
"""Import `scripts/odysseus-dev` as a module.
Same loader the CLI tests use. Delegating by import rather than by
parsing `odysseus dev env` output means the port derivation and the
credential handling have exactly one implementation.
"""
path = SCRIPTS_DIR / DEV_SCRIPT
if not path.exists():
fail(f"{path} is missing; this tool boots through it.", code=2)
loader = importlib.machinery.SourceFileLoader("odysseus_dev_cli", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def suite_environment(dev, root, ports, account):
"""The environment the smoke suite reads its target instance from.
Deliberately the same values `odysseus dev env` prints, plus the dev
admin account, so a manual `pytest tests/smoke` under
`eval $(odysseus dev env)` behaves the way this tool does.
"""
data_dir = dev.dev_dir(root) / "data"
env = dict(os.environ)
env.update({
"APP_PORT": str(ports["app"]),
"CHROMADB_PORT": str(ports["chroma"]),
"ODYSSEUS_TEST_STATIC_PORT": str(ports["test_static"]),
"ODYSSEUS_DATA_DIR": str(data_dir),
"DATABASE_URL": f"sqlite:///{data_dir / 'app.db'}",
"ODYSSEUS_ADMIN_USER": account["username"],
"ODYSSEUS_ADMIN_PASSWORD": account["password"],
})
return env
def boot(dev, root, args):
"""Start the instance, or adopt one already running in this worktree.
Returns (started_by_us, note). A reused instance is never restarted
without being asked: it may be someone's debugging session, and the
one thing it can cost us is the email fixture flag, which the suite
reports as a skip rather than a pass.
"""
already = dev.running_app(dev.read_state(root))
if already and args.restart:
subprocess.run([sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "down"],
cwd=str(root), check=False)
already = None
if already:
return False, (
f"reusing the instance already up on port {already['port']} "
f"(pid {already['pid']}). If it was not booted with "
f"{EMAIL_FIXTURE_ENV}=1 the Email area will report a skip; "
f"re-run with --restart for a clean boot."
)
if args.no_boot:
fail(
"nothing is running in this worktree and --no-boot was passed.\n"
" boot it with `odysseus dev up`, or drop --no-boot.",
)
command = [sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "up"]
if args.venv:
command += ["--venv", args.venv]
env = dict(os.environ)
env[EMAIL_FIXTURE_ENV] = "1"
result = subprocess.run(command, cwd=str(root), env=env, check=False)
if result.returncode != 0:
fail(f"`odysseus dev up` exited {result.returncode}; not running the suite.")
return True, ""
def venv_python(dev, root, args):
"""The interpreter to run pytest with: the one the app runs under."""
recorded = (dev.read_state(root) or {}).get("venv")
for candidate in (Path(args.venv).expanduser() if args.venv else None,
Path(recorded) if recorded else None,
Path(root) / "venv"):
if candidate and (candidate / "bin" / "python").exists():
return candidate / "bin" / "python"
fail(
f"no interpreter found for the suite (looked at {Path(root) / 'venv'}).\n"
f" build one with ./start-macos.sh, or pass --venv."
)
def cmd_run(args):
dev = load_dev()
root = dev.find_repo_root(Path.cwd())
if root is None:
fail(f"not inside an Odysseus checkout (looked upwards from {Path.cwd()})", code=2)
if args.areas:
sys.path.insert(0, str(root))
from tests.smoke import areas
sys.stdout.write(areas.render_table({}, header="Odysseus release smoke - coverage") + "\n")
return 0
ports = dev.derive_ports(root)
account = dev.credentials(root)
started_by_us, note = boot(dev, root, args)
if note:
sys.stdout.write(f" {note}\n")
python = venv_python(dev, root, args)
env = suite_environment(dev, root, ports, account)
command = [str(python), "-m", "pytest", SMOKE_SUITE, "-q"] + list(args.pytest_args)
sys.stdout.write(f"\n running {SMOKE_SUITE} against http://127.0.0.1:{ports['app']}\n\n")
# Flush before handing the terminal to pytest, or our own lines land
# after its output and the report reads out of order.
sys.stdout.flush()
result = subprocess.run(command, cwd=str(root), env=env, check=False)
if started_by_us and not args.keep_up:
subprocess.run([sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "down"],
cwd=str(root), check=False)
elif started_by_us:
sys.stdout.write(
f"\n left running: http://127.0.0.1:{ports['app']} "
f"({account['username']} / {account['password']})\n"
f" stop it with `odysseus dev down`\n"
)
# `cli.run` discards a returned value but lets SystemExit through, and
# a smoke run's exit code is the whole point of having one command.
if result.returncode != 0:
raise SystemExit(result.returncode)
return 0
def build_parser():
parser = common_parser("odysseus-smoke",
"Boot this worktree and run the release smoke suite.")
parser.add_argument("--keep-up", action="store_true",
help="leave the instance running after the suite finishes")
parser.add_argument("--no-boot", action="store_true",
help="require an instance already up in this worktree")
parser.add_argument("--restart", action="store_true",
help="stop a running instance and boot a fresh one")
parser.add_argument("--venv", help="use this venv instead of ./venv")
parser.add_argument("--areas", action="store_true",
help="print the coverage table and exit without booting")
parser.add_argument("pytest_args", nargs="*", metavar="-- PYTEST ARGS",
help="arguments forwarded to pytest after a literal --")
parser.set_defaults(func=cmd_run)
return parser
if __name__ == "__main__":
sys.exit(run(build_parser()))
+586
View File
@@ -0,0 +1,586 @@
#!/usr/bin/env python3
"""Read-only audit of what one git ref carries that another does not.
Two long-lived lines that are not merged into each other drift silently. A fix
landed on one of them leaves no mark on the other, and nothing in git tells you
so: the two histories share only a distant merge base, so `git log A..B` lists
thousands of commits whose content is in fact already present on both sides
under different SHAs.
This script answers the question that actually matters at release time -- which
commits on the source ref left *no trace at all* in the target ref -- by
sampling distinctive added lines from each commit and searching the target tree
for them. It also reports the file-level presence diff, which catches the case
the line sampling cannot: a fix whose production change was reproduced on the
target but whose test file was never brought over.
It is read-only. It runs `git log`, `git show`, `git diff`, `git grep`,
`git ls-tree` and `git merge-base`, writes nothing to the repository, touches no
remote, and does not import the Odysseus application package.
Usage:
scripts/ref_parity_audit.py --source public/dev --target lab --since 2026-08-10
Read `docs/ref-parity-audit.md` before acting on the output: the line sampling
is a heuristic and the report labels which of its verdicts are exact.
"""
import argparse
import fnmatch
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Sequence
REPO_ROOT = Path(__file__).resolve().parents[1]
# Paths whose contents are never worth probing: vendored third-party code,
# committed build output, lockfiles and binaries. A distinctive line does not
# exist in a minified bundle, and a lockfile churns on every dependency bump.
DEFAULT_EXCLUDES = (
"static/lib/*",
"static/js/editor/build/*",
"*.min.js",
"*.min.css",
"*.map",
"package-lock.json",
"*.lock",
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.ico",
"*.webp",
"*.svg",
"*.pdf",
"*.woff",
"*.woff2",
"*.ttf",
"*.otf",
"*.mp3",
"*.mp4",
"*.wav",
"*.zip",
"*.gz",
)
# A probe has to be long enough and carry enough named things to be unlikely to
# appear by coincidence. `return hosts` is in a hundred files; a line naming two
# identifiers over 24 characters is usually unique to the change that added it.
MIN_PROBE_LENGTH = 24
MIN_PROBE_IDENTIFIERS = 2
IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{2,}")
FIELD_SEP = "\x1f"
VERDICT_ABSENT = "absent"
VERDICT_PARTIAL = "partial"
VERDICT_PRESENT = "present"
VERDICT_NO_PROBE = "no-probe"
class GitError(RuntimeError):
"""A git invocation failed in a way the audit cannot work around."""
@dataclass
class Commit:
sha: str
author: str
date: str
subject: str
parent_count: int
@dataclass
class CommitVerdict:
commit: Commit
probes: tuple[str, ...]
found: tuple[str, ...]
paths: tuple[str, ...]
@property
def verdict(self) -> str:
if not self.probes:
return VERDICT_NO_PROBE
if not self.found:
return VERDICT_ABSENT
if len(self.found) < len(self.probes):
return VERDICT_PARTIAL
return VERDICT_PRESENT
@dataclass
class Report:
source: str
source_sha: str
target: str
target_sha: str
merge_base: str
since: str | None
until: str | None
traversal: str
probe_limit: int
verdicts: list[CommitVerdict] = field(default_factory=list)
source_only_files: tuple[str, ...] = ()
target_only_files: tuple[str, ...] = ()
def by_verdict(self, verdict: str) -> list[CommitVerdict]:
return [v for v in self.verdicts if v.verdict == verdict]
# --------------------------------------------------------------------------- #
# git plumbing
# --------------------------------------------------------------------------- #
def run_git(args: Sequence[str], repo: Path) -> str:
"""Run a read-only git command and return stdout, raising on failure."""
proc = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
)
if proc.returncode != 0:
raise GitError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
return proc.stdout
def resolve_ref(ref: str, repo: Path) -> str:
return run_git(["rev-parse", "--short=8", ref], repo).strip()
def merge_base(source: str, target: str, repo: Path) -> str:
try:
return run_git(["merge-base", source, target], repo).strip()[:8]
except GitError:
# Unrelated histories have no merge base. That is a finding, not a crash.
return ""
def list_commits(
source: str,
target: str,
repo: Path,
since: str | None = None,
until: str | None = None,
traversal: str = "linear",
) -> list[Commit]:
"""List commits reachable from `source` but not from `target`.
`linear` drops merge commits and reports the individual authored commits,
which is what finds a fix that arrived on a side branch. `first-parent`
reports one entry per merge into the source branch, which reads as one row
per merged pull request.
"""
args = [
"log",
"--date=short",
f"--format=%H{FIELD_SEP}%an{FIELD_SEP}%cd{FIELD_SEP}%p{FIELD_SEP}%s",
]
args.append("--no-merges" if traversal == "linear" else "--first-parent")
if since:
args.append(f"--since={since}")
if until:
args.append(f"--until={until}")
args.append(f"{target}..{source}")
commits = []
for line in run_git(args, repo).splitlines():
if not line.strip():
continue
sha, author, date, parents, subject = line.split(FIELD_SEP, 4)
commits.append(
Commit(
sha=sha,
author=author,
date=date,
subject=subject,
parent_count=len(parents.split()) if parents.strip() else 0,
)
)
return commits
def commit_diff(commit: Commit, repo: Path) -> str:
"""Return the commit's patch with no context lines.
A merge is diffed against its first parent so the whole merged content is
visible; `git show` would otherwise print only the conflicting hunks.
"""
if commit.parent_count > 1:
return run_git(
["diff", "--no-color", "--no-renames", "-U0", f"{commit.sha}^1", commit.sha],
repo,
)
return run_git(
["show", "--no-color", "--no-renames", "-U0", "--format=", commit.sha], repo
)
def probe_present(probe: str, ref: str, repo: Path) -> bool:
"""Is this exact text anywhere in the ref's tree?
The whole tree is searched on purpose. The question is whether the change
left a trace at all, not whether it landed in the same file -- a ported fix
routinely moves, and the exclusion list only governs where probes come
from.
"""
proc = subprocess.run(
["git", "-C", str(repo), "grep", "--fixed-strings", "--quiet", "-e", probe, ref],
capture_output=True,
text=True,
)
if proc.returncode not in (0, 1):
raise GitError(f"git grep failed for {ref}: {proc.stderr.strip()}")
return proc.returncode == 0
def list_tree(ref: str, repo: Path) -> list[str]:
raw = run_git(["ls-tree", "-r", "-z", "--name-only", ref], repo)
return [path for path in raw.split("\0") if path]
# --------------------------------------------------------------------------- #
# probe selection (pure)
# --------------------------------------------------------------------------- #
def is_excluded(path: str, patterns: Iterable[str]) -> bool:
name = path.rsplit("/", 1)[-1]
return any(
fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(name, pattern)
for pattern in patterns
)
def added_lines(patch: str, excludes: Iterable[str]) -> list[tuple[str, str]]:
"""Extract `(path, added line)` pairs from a unified diff."""
results = []
path = None
skip = False
for line in patch.splitlines():
if line.startswith("+++ "):
target = line[4:].strip()
path = None if target == "/dev/null" else target[2:] if target.startswith("b/") else target
skip = path is None or is_excluded(path, excludes)
elif line.startswith("--- ") or line.startswith("diff --git "):
continue
elif line.startswith("+") and path and not skip:
results.append((path, line[1:]))
return results
def probe_score(text: str) -> int:
"""Rank a candidate probe: distinct named things first, then length."""
identifiers = set(IDENTIFIER_RE.findall(text))
return len(identifiers) * 1000 + min(len(text), 400)
def is_probe_candidate(text: str) -> bool:
stripped = text.strip()
if len(stripped) < MIN_PROBE_LENGTH:
return False
if "\0" in stripped:
return False
return len(set(IDENTIFIER_RE.findall(stripped))) >= MIN_PROBE_IDENTIFIERS
def pick_probes(lines: Sequence[tuple[str, str]], limit: int) -> list[str]:
"""Pick up to `limit` distinctive stripped lines, highest-scoring first.
Leading and trailing whitespace is dropped so a re-indented port still
counts as present. Ties break on first appearance, keeping the output
stable across runs.
"""
seen: dict[str, int] = {}
for index, (_path, text) in enumerate(lines):
stripped = text.strip()
if not is_probe_candidate(stripped) or stripped in seen:
continue
seen[stripped] = index
ranked = sorted(seen, key=lambda text: (-probe_score(text), seen[text]))
return ranked[:limit]
# --------------------------------------------------------------------------- #
# audit
# --------------------------------------------------------------------------- #
def audit(
source: str,
target: str,
repo: Path,
since: str | None = None,
until: str | None = None,
traversal: str = "linear",
probe_limit: int = 4,
excludes: Sequence[str] = DEFAULT_EXCLUDES,
progress: bool = False,
) -> Report:
report = Report(
source=source,
source_sha=resolve_ref(source, repo),
target=target,
target_sha=resolve_ref(target, repo),
merge_base=merge_base(source, target, repo),
since=since,
until=until,
traversal=traversal,
probe_limit=probe_limit,
)
commits = list_commits(source, target, repo, since, until, traversal)
for index, commit in enumerate(commits, start=1):
if progress:
print(
f"\r[{index}/{len(commits)}] {commit.sha[:8]}",
end="",
file=sys.stderr,
flush=True,
)
lines = added_lines(commit_diff(commit, repo), excludes)
probes = pick_probes(lines, probe_limit)
found = tuple(p for p in probes if probe_present(p, target, repo))
report.verdicts.append(
CommitVerdict(
commit=commit,
probes=tuple(probes),
found=found,
paths=tuple(dict.fromkeys(path for path, _ in lines)),
)
)
if progress:
print("", file=sys.stderr)
source_files = {p for p in list_tree(source, repo) if not is_excluded(p, excludes)}
target_files = {p for p in list_tree(target, repo) if not is_excluded(p, excludes)}
report.source_only_files = tuple(sorted(source_files - target_files))
report.target_only_files = tuple(sorted(target_files - source_files))
return report
# --------------------------------------------------------------------------- #
# rendering
# --------------------------------------------------------------------------- #
def _commit_table(verdicts: Sequence[CommitVerdict]) -> list[str]:
rows = [
"| Commit | Committed | Author | Probes found | Subject |",
"|---|---|---|---|---|",
]
for item in verdicts:
rows.append(
f"| `{item.commit.sha[:8]}` | {item.commit.date} | {item.commit.author} "
f"| {len(item.found)}/{len(item.probes)} | {item.commit.subject} |"
)
return rows
def _file_list(paths: Sequence[str], top: int) -> list[str]:
lines = [f"- `{path}`" for path in paths[:top]]
if len(paths) > top:
lines.append(f"- … and {len(paths) - top} more")
return lines
def render_markdown(report: Report, top: int = 50) -> str:
absent = report.by_verdict(VERDICT_ABSENT)
partial = report.by_verdict(VERDICT_PARTIAL)
present = report.by_verdict(VERDICT_PRESENT)
no_probe = report.by_verdict(VERDICT_NO_PROBE)
window = []
if report.since:
window.append(f"since {report.since}")
if report.until:
window.append(f"until {report.until}")
out = [
"# Ref parity audit",
"",
f"Source `{report.source}` @ `{report.source_sha}` → "
f"target `{report.target}` @ `{report.target_sha}`.",
f"Merge base `{report.merge_base or 'none (unrelated histories)'}`.",
f"{len(report.verdicts)} commits on the source and not the target "
f"({report.traversal} traversal"
+ (", " + ", ".join(window) if window else "")
+ f"), up to {report.probe_limit} probes each.",
"",
f"No trace in the target: **{len(absent)}**. "
f"Partly present: **{len(partial)}**. "
f"Fully present: **{len(present)}**. "
f"Unprobeable: **{len(no_probe)}**.",
"",
"## Commits with no trace in the target",
"",
]
out += _commit_table(absent) if absent else ["None."]
out += [
"",
"## Commits only partly present",
"",
"A partial verdict is inconclusive, not a finding: a line can move or be "
"rewritten by a refactor on the target and still be the same change. Read the "
"diff before porting anything from this table.",
"",
]
out += _commit_table(partial) if partial else ["None."]
out += ["", "## Commits with no usable probe", ""]
if no_probe:
out += [
"Deletion-only commits, and commits touching nothing but excluded paths. "
"The audit has no verdict on these.",
"",
] + _commit_table(no_probe)
else:
out.append("None.")
out += [
"",
f"## Files on the source and not the target ({len(report.source_only_files)})",
"",
"Exact, not sampled. A file here whose commit is reported fully present is "
"usually a fix that was reproduced without its test.",
"",
]
out += _file_list(report.source_only_files, top) if report.source_only_files else ["None."]
out += [
"",
f"## Files on the target and not the source ({len(report.target_only_files)})",
"",
]
out += _file_list(report.target_only_files, top) if report.target_only_files else ["None."]
out.append("")
return "\n".join(out)
def render_json(report: Report) -> str:
return json.dumps(
{
"source": {"ref": report.source, "sha": report.source_sha},
"target": {"ref": report.target, "sha": report.target_sha},
"merge_base": report.merge_base,
"since": report.since,
"until": report.until,
"traversal": report.traversal,
"probe_limit": report.probe_limit,
"totals": {
verdict: len(report.by_verdict(verdict))
for verdict in (
VERDICT_ABSENT,
VERDICT_PARTIAL,
VERDICT_PRESENT,
VERDICT_NO_PROBE,
)
},
"commits": [
{
"sha": item.commit.sha,
"date": item.commit.date,
"author": item.commit.author,
"subject": item.commit.subject,
"verdict": item.verdict,
"probes": list(item.probes),
"probes_found": list(item.found),
"paths": list(item.paths),
}
for item in report.verdicts
],
"source_only_files": list(report.source_only_files),
"target_only_files": list(report.target_only_files),
},
indent=2,
sort_keys=True,
)
# --------------------------------------------------------------------------- #
# cli
# --------------------------------------------------------------------------- #
def positive_int(value: str) -> int:
parsed = int(value)
if parsed < 1:
raise argparse.ArgumentTypeError("must be 1 or greater")
return parsed
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Read-only audit of which commits on one ref left no trace in another."
)
parser.add_argument("--source", required=True, help="Ref whose commits are audited")
parser.add_argument("--target", required=True, help="Ref searched for traces of them")
parser.add_argument("--repo", default=str(REPO_ROOT), help="Repository to run in")
parser.add_argument("--since", help="Only commits committed on or after this date")
parser.add_argument("--until", help="Only commits committed on or before this date")
parser.add_argument(
"--traversal",
choices=["linear", "first-parent"],
default="linear",
help="linear: individual commits, no merges. first-parent: one row per merge",
)
parser.add_argument(
"--probes", type=positive_int, default=4, help="Probe lines sampled per commit"
)
parser.add_argument(
"--exclude",
action="append",
default=[],
metavar="GLOB",
help="Extra path glob whose lines are not used as probes (repeatable)",
)
parser.add_argument(
"--no-default-excludes",
action="store_true",
help="Drop the built-in vendored/lockfile/binary exclusions",
)
parser.add_argument("--format", choices=["markdown", "json"], default="markdown")
parser.add_argument("--top", type=positive_int, default=50, help="Rows per file list")
parser.add_argument("--output", help="Write the report here instead of stdout")
parser.add_argument("--quiet", action="store_true", help="No progress output")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
excludes = list(args.exclude)
if not args.no_default_excludes:
excludes = list(DEFAULT_EXCLUDES) + excludes
try:
report = audit(
source=args.source,
target=args.target,
repo=Path(args.repo),
since=args.since,
until=args.until,
traversal=args.traversal,
probe_limit=args.probes,
excludes=excludes,
progress=not args.quiet and sys.stderr.isatty(),
)
except GitError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
text = render_json(report) if args.format == "json" else render_markdown(report, args.top)
if args.output:
Path(args.output).write_text(text + "\n", encoding="utf-8")
else:
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+18 -2
View File
@@ -1,10 +1,26 @@
/** Card layout and reconciliation against the served assets; no user mutations. */
import { chromium } from 'playwright';
const ORIGIN = 'http://127.0.0.1:7011';
/** The `<link rel="stylesheet">` tags the app shell ships, in shell order. */
async function shellStylesheets() {
const response = await fetch(`${ORIGIN}/static/index.html`);
if (!response.ok) throw new Error(`static/index.html returned ${response.status}`);
const links = (await response.text()).match(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi) || [];
if (!links.length) throw new Error('no stylesheet links found in static/index.html');
return links.join('');
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto('http://127.0.0.1:7011/static/test-fixtures/browser-catalog.html');
await page.setContent('<link rel="stylesheet" href="/static/style.css"><main style="padding:16px"><div id="chat-history"><p>Existing conversation</p></div></main>');
await page.goto(`${ORIGIN}/static/test-fixtures/browser-catalog.html`);
// Read the shell's stylesheets out of index.html rather than naming one
// here. style.css is now a set of ordered fragments, and a hardcoded link
// to a file that has moved does not fail - it renders unstyled and the
// layout checks below pass against nothing.
await page.setContent(`${await shellStylesheets()}<main style="padding:16px"><div id="chat-history"><p>Existing conversation</p></div></main>`);
const checks = await page.evaluate(async () => {
const { renderResearchCards } = await import('/static/js/backgroundToolJobs.js');
const box = document.querySelector('#chat-history');
+118 -19
View File
@@ -3,11 +3,19 @@
import json
import logging
import re
import time
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor, as_completed
from contextlib import contextmanager
from contextvars import ContextVar
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List, Set
from urllib.parse import urlparse
from src.constants import (
ARXIV_API_URL,
OPENALEX_API_URL,
SCHOLARLY_LOOKUP_TOTAL_BUDGET,
)
from src.search_passages import search_excerpt
import httpx
@@ -489,22 +497,106 @@ def _result_strongly_matches_title(title: str, result: dict) -> bool:
return overlap >= (1.0 if len(wanted) == 2 else 0.8)
def _scholarly_user_agent() -> str:
"""Identify this build to the scholarly APIs using the real app version."""
from src.constants import APP_VERSION
return f"Odysseus/{APP_VERSION} scholarly-title-resolver"
_scholarly_deadline: ContextVar[Optional[float]] = ContextVar(
"scholarly_deadline", default=None
)
@contextmanager
def _scholarly_budget():
"""Open one wall-clock budget shared by every hop of a lookup chain."""
token = _scholarly_deadline.set(
time.monotonic() + SCHOLARLY_LOOKUP_TOTAL_BUDGET
)
try:
yield
finally:
_scholarly_deadline.reset(token)
MAX_SCHOLARLY_REDIRECTS = 3
def _scholarly_api_get(url: str, params: dict) -> Optional[httpx.Response]:
"""GET a scholarly metadata API under the shared outbound policy.
Returns ``None`` when any destination URL fails the outbound check or the
caller's budget is already spent, so callers degrade to their next source
instead of raising. Bounded manual redirects ensure every hop passes
through ``check_outbound_url`` before the destination is contacted.
"""
from src.constants import SCHOLARLY_LOOKUP_TIMEOUT
from src.url_safety import check_outbound_url
current_url = url
current_params: Optional[dict] = params
for _ in range(MAX_SCHOLARLY_REDIRECTS + 1):
ok, reason = check_outbound_url(current_url, block_private=True)
if not ok:
logger.warning("Scholarly lookup blocked for %s: %s", current_url, reason)
return None
timeout = SCHOLARLY_LOOKUP_TIMEOUT
deadline = _scholarly_deadline.get()
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
logger.info("Scholarly lookup budget exhausted before %s", current_url)
return None
timeout = min(timeout, remaining)
response = httpx.get(
current_url,
params=current_params,
headers={"User-Agent": _scholarly_user_agent()},
timeout=timeout,
follow_redirects=False,
)
is_redirect = getattr(response, "is_redirect", False) or (
getattr(response, "status_code", None) in (301, 302, 303, 307, 308)
)
if is_redirect:
headers = getattr(response, "headers", {})
location = headers.get("location")
if not location:
logger.warning(
"Scholarly redirect missing Location header from %s", current_url
)
return None
current_url = str(httpx.URL(str(response.url)).join(location))
current_params = None
continue
response.raise_for_status()
return response
logger.warning("Scholarly lookup exceeded max redirects from %s", url)
return None
def _arxiv_title_results(title: str, count: int = 3) -> list[dict]:
"""Resolve a paper title through arXiv's public Atom API."""
try:
response = httpx.get(
"https://export.arxiv.org/api/query",
params={
response = _scholarly_api_get(
ARXIV_API_URL,
{
"search_query": f'ti:"{title}"',
"start": 0,
"max_results": max(1, min(int(count), 5)),
},
headers={"User-Agent": "Odysseus/0.20 scholarly-title-resolver"},
timeout=12.0,
follow_redirects=True,
)
response.raise_for_status()
if response is None:
return []
root = ET.fromstring(response.text)
except Exception as exc:
logger.info("arXiv title lookup failed for %r: %s", title, exc)
@@ -541,20 +633,18 @@ def _openalex_title_results(title: str, count: int = 3) -> list[dict]:
# OpenAlex treats a literal question mark as query syntax and returns
# HTTP 400 for otherwise valid titles such as "How Far ... GPT-4V?".
search_title = re.sub(r"[?]+", " ", str(title or "")).strip()
response = httpx.get(
"https://api.openalex.org/works",
params={
response = _scholarly_api_get(
OPENALEX_API_URL,
{
"search": search_title,
"per-page": max(1, min(int(count), 5)),
"select": (
"display_name,doi,primary_location,publication_year,type"
),
},
headers={"User-Agent": "Odysseus/0.20 scholarly-title-resolver"},
timeout=12.0,
follow_redirects=True,
)
response.raise_for_status()
if response is None:
return []
payload = response.json()
except Exception as exc:
logger.info("OpenAlex title lookup failed for %r: %s", title, exc)
@@ -597,8 +687,16 @@ def _openalex_title_results(title: str, count: int = 3) -> list[dict]:
def _scholarly_title_results(title: str, count: int = 3) -> list[dict]:
"""Retry a noisy scholarly query as a bare title, then use arXiv API."""
"""Retry a noisy scholarly query as a bare title, then use arXiv API.
The three hops share one wall-clock budget so a slow upstream cannot hold a
user-facing search open for the sum of every per-request timeout.
"""
with _scholarly_budget():
return _scholarly_title_results_inner(title, count)
def _scholarly_title_results_inner(title: str, count: int) -> list[dict]:
try:
simplified = searxng_search_api(title, count=max(3, count))
except Exception as exc:
@@ -621,10 +719,11 @@ def _direct_scholarly_title_results(title: str, count: int = 3) -> list[dict]:
# OpenAlex typically resolves titles in under a second and often returns
# the official arXiv landing page. The arXiv API remains the fallback.
openalex = _openalex_title_results(title, count)
if openalex:
return openalex
return _arxiv_title_results(title, count)
with _scholarly_budget():
openalex = _openalex_title_results(title, count)
if openalex:
return openalex
return _arxiv_title_results(title, count)
def _augment_scholarly_results(query: str, results: list[dict], count: int) -> list[dict]:
+2 -1
View File
@@ -8,7 +8,8 @@ This spec covers mail and contacts in:
- app wiring in `app.py`;
- `core.database.EmailAccount`;
- `routes/email_routes.py`, `routes/email_helpers.py`, and `routes/email_pollers.py`;
- canonical `routes/email/email_routes.py`, `routes/email/email_helpers.py`, and
`routes/email/email_pollers.py`, plus their shims at the old flat paths;
- email threading in `src/email_thread_parser.py`;
- email MCP tools in `mcp_servers/email_server.py`;
- canonical contact/CardDAV routes in `routes/contacts/contacts_routes.py`,
+7 -3
View File
@@ -97,6 +97,8 @@ Current major frontend areas include:
- image editor integration in `static/js/galleryEditor.js` plus leaves under `static/js/editor/`;
- gallery, email inbox/library, calendar, research panel/jobs/synapse, notes/tasks, assistant, memory/skills, Cookbook/HW Fit, workspace picker, provider device flow, composer ArrowUp recall, theme, modal/window utilities, storage, and accessibility helpers.
`static/js/emailLibrary/` is the one JS package with module-graph coverage: `tests/test_email_library_module_graph_js.py` pins the wrapper's public surface against the entry module's exports, evaluates every module on its own in a browser so an import cycle cannot hide a temporal-dead-zone read, and requires each module to be in the `sw.js` precache. Assertions about its source go through `tests/helpers/js_modules.py`, which reads the whole package, for the same reason `tests/helpers/stylesheets.py` reads the whole cascade.
Coordinator ownership:
- `static/app.js` owns late orchestration, global fetch 401 redirects, sidebar/tool route wiring, and many `window.*` compatibility bridges;
@@ -136,6 +138,8 @@ The Settings finder and navigation are registry-backed, hide admin-only destinat
Existing frontend coverage is a mix of Node-executed helper tests, `.mjs` tests, static DOM/CSS/source-shape tests, browser exploration specs, and app/static tests. Many tests are useful source-shape regressions but do not replace browser/module-graph execution.
`tests/test_css_computed_style_snapshot.py` pins `getComputedStyle` for a fixed element inventory across pages, viewports, themes and density modes, so a `static/style.css` restructuring that changes which declaration wins fails a test instead of shipping; see `tests/css_snapshot/README.md` for what it does and does not cover.
Recent focused coverage includes model-key matching under Node, document-library counters, chat resend/delete/mobile Enter/ArrowUp, scoped approval continuation and compare routing, route provenance, live-thinking throttling, startup shell/history hydration, shared app-config caching/invalidation, settings registry/navigation/finder/lifecycle, lazy panel loading/offline editor precache, vendored lazy KaTeX/Mermaid rendering, email read dedup/prewarm, Markdown restoration, malformed keybinds, currency-safe inline math, notes/calendar/modal/manifest/admin-log behavior, Markdown XSS helpers, and CardDAV unchanged-password handling.
Missing coverage includes:
@@ -143,7 +147,7 @@ Missing coverage includes:
- SPA route/static auth and no-cache headers;
- CSP header contents and nonce injection for `/` and `/login`;
- service-worker API/non-GET bypass and cache strategy;
- service-worker precache versus `index.html` script/module tags, including query strings;
- service-worker precache versus `index.html` script/module tags, including query strings (stylesheet links and their `?v=` strings are covered by `tests/test_static_stylesheet_manifest.py`; script and module tags are not);
- ongoing manifest/icon reference drift;
- module graph/load-order validation;
- degraded vendor-library/browser API behavior, including Pyodide's remaining CDN path.
@@ -151,8 +155,8 @@ Missing coverage includes:
## Current Gaps
- `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
- There is no build-time type checking, module graph validation, script-order validation, or service-worker precache validation.
- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets, and for the `static/js/emailLibrary/` package only.
- Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care.
- `window.*` compatibility bridges remain widespread.
- PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing.
- A static asset/route manifest regression should verify files referenced by `index.html`, `manifest.json`, `sw.js`, and app-owned HTML routes actually exist.
- The static asset/route manifest regression covers stylesheets referenced by app-owned HTML and `sw.js`; scripts, modules and `manifest.json` icon references are still unverified.
+2 -2
View File
@@ -16,7 +16,7 @@ This spec covers durable state in:
- `src/attachment_refs.py`, `src/upload_handler.py`, and
`routes/upload_routes.py` for durable upload references and retention;
- JSON stores managed by `core/auth.py`, `src/settings.py`, `src/api_key_manager.py`, `src/preset_manager.py`, `src/integrations.py`, `src/upload_handler.py`, `src/personal_docs.py`, `src/research_handler.py`, `src/bg_jobs.py`, `routes/prefs_routes.py`, canonical `routes/contacts/contacts_routes.py` and `routes/vault/vault_routes.py` plus their shims, `routes/cookbook_routes.py`, and memory/skills managers;
- `routes/email_helpers.py` scheduled-email storage;
- canonical `routes/email/email_helpers.py` scheduled-email storage;
- `routes/backup_routes.py` and `scripts/odysseus-backup`;
- runtime data under `data/`.
@@ -61,7 +61,7 @@ Email default-account state is serialized per owner. Startup normalizes legacy d
`core/models.py` owns pure dataclasses used by `SessionManager`. It does not own database persistence.
`routes/email_helpers.py` owns a second SQLite database at `data/scheduled_emails.db` for scheduled email, summary, reply, tag, sender-signature, urgency-alert, calendar-extraction, and cache state. Its migrations and owner backfills are local to that module, not `core/database.py`, and those auxiliary tables are owner-scoped.
`routes/email/email_helpers.py` owns a second SQLite database at `data/scheduled_emails.db` for scheduled email, summary, reply, tag, sender-signature, urgency-alert, calendar-extraction, and cache state. Its migrations and owner backfills are local to that module, not `core/database.py`, and those auxiliary tables are owner-scoped.
## Migration Policy
+4 -4
View File
@@ -20,7 +20,7 @@ This spec covers settings and admin surfaces in:
- `routes/model_routes.py` for `/api/tools` and settings-bound model endpoint references;
- `src/agent_tools/admin_tools.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_index.py` for `manage_settings`;
- `src/agent_loop.py` for stale agent prompt references to settings APIs;
- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`;
- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar,shell,peek,oauthReturn}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`;
- CLI helpers `scripts/odysseus-preset` and `scripts/odysseus-theme`.
Generic API integrations are cross-referenced in `integrations.md`. Model endpoint CRUD and endpoint cleanup are covered in `llm-models.md`. Email/contact/calendar legacy setting fallbacks stay with their domain specs.
@@ -75,7 +75,7 @@ Admin gates inherit the auth contracts in `auth-security.md`: normal deployments
- bundled accessibility font selection such as OpenDyslexic and text-size variable application;
- CSS variable application.
`static/js/settings.js` owns domain panel load/save behavior and compatibility exports, while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state.
`static/js/settings.js` owns domain panel load/save behavior and compatibility exports, while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. `shell.js` owns the coordination above those primitives — per-panel activation side effects, the admin-controller handoff, `.admin-only` visibility, and the public `open()`/`close()` entry points — holding no panel state of its own and taking what it needs from `settings.js` as injected callbacks. `peek.js` owns the Appearance window-fade chrome and clearing it when the user leaves that panel, and `oauthReturn.js` owns the once-per-load return path from the Google OAuth redirect into the Integrations panel. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state.
Settings panels cover provider/model/search/research/reminder/email/CalDAV/CardDAV/vault, accessibility/font/text-size, scoped tokens, and unified integrations. The hidden legacy fallback editor was removed; no current Settings panel exposes the new foreground fallback keys, so opt-in exists only through owner-scoped preferences/internal callers until a deliberate UI is added. Email OAuth connect preserves the selected SMTP security mode and returns to the Settings surface after callback. `static/js/admin.js` owns user/admin panels, model endpoints, builtin tool toggles, MCP forms, feature toggles, token/webhook panels, diagnostics, backup/import, and danger-zone wipes.
@@ -184,7 +184,7 @@ Current targeted coverage includes settings store fallback/error paths, settings
- Add diagnostics tests for broader error redaction and sensitive output limits.
- Add admin wipe tests for every wipe kind, unknown-kind 400, rollback behavior, and admin gating.
- Add vault route tests for session omission, permission setting, login/unlock failures, lock/logout clearing, corrupt config, and admin gates.
- Add broader frontend behavior coverage for Settings/Admin panel save/load flows, vault password clearing, diagnostics buttons, cleanup/wipe confirmations, custom font/theme wiring, and tab state; registry/navigation/finder/lifecycle contracts now have focused source/JS tests.
- Add broader frontend behavior coverage for Settings/Admin panel save/load flows, vault password clearing, diagnostics buttons, cleanup/wipe confirmations, custom font/theme wiring, and tab state; registry/navigation/finder/lifecycle contracts now have focused source/JS tests, and the real-ESM coordinator smoke in `tests/helpers/test_settings_shell_coordinator.mjs` additionally covers the shell's admin-only visibility, admin-controller handoff and Peek fade/clear behavior.
- Decide whether `user_templates` and `group_presets` should remain shared despite user-facing names.
- Decide whether backup/import should preserve explicit owner fields or force imported owner ownership.
- Continue moving shell/navigation concerns out of the still-large `static/js/settings.js` and `static/js/admin.js` domain boundary without duplicating registry ownership.
- Continue decomposing the still-large `static/js/settings.js` (5,583 lines) and `static/js/admin.js` (4,122 lines) without duplicating registry ownership. The shell and navigation concerns now live in `static/js/settings/`; what remains in `settings.js` is panel code, and `initUnifiedIntegrations()` alone is ~2,140 lines of it. `admin.js` has no extracted shell of its own yet.
+32 -8
View File
@@ -22473,7 +22473,7 @@ async def stream_agent_loop(
"manage_notes", "manage_calendar", "manage_tasks",
"ask_user", "update_plan",
}
elif _ody_doc_finetune_mode and route_tools is not None:
elif (_ody_doc_finetune_mode or doc_mode) and route_tools is not None:
if _prompt_active_document is not None:
route_tools = {
"edit_document", "update_document", "suggest_document",
@@ -22481,12 +22481,12 @@ async def stream_agent_loop(
}
else:
route_tools = {"create_document", "ask_user", "update_plan"}
elif _ody_notes_finetune_mode and route_tools is not None:
elif (_ody_notes_finetune_mode or notes_mode) and route_tools is not None:
route_tools = {
"manage_notes", "manage_calendar", "manage_tasks",
"ask_user", "update_plan",
}
elif _ody_general_no_tool_mode:
elif _ody_general_no_tool_mode or general_no_tool_mode:
route_tools = set()
else:
route_tools = _route_tui_local_workspace_tools(
@@ -22984,6 +22984,8 @@ async def stream_agent_loop(
# navigation tools. Do not let the general agent floor re-add bash
# after that narrow surface was selected.
and not (_low_signal_turn and workspace)
and not _ody_notes_finetune_mode
and not _ody_general_no_tool_mode
):
from src.turn_contract import CONTRACT_CORE_TOOLS
_core_agent_tools = set(CONTRACT_CORE_TOOLS)
@@ -23231,6 +23233,13 @@ async def stream_agent_loop(
_base_relevant_tools = set(_relevant_tools)
logger.info("[agent-intent] explicit plan request clamped to plan tools")
if _low_signal_turn and not workspace and not _terminal_agent_mode and _relevant_tools is not None:
# Retrieval and the core floor can surface file readers for a vague
# local-project hint even though no project has been selected.
_relevant_tools.difference_update(_DOMAIN_TOOL_MAP["files"])
if _base_relevant_tools is not None:
_base_relevant_tools.difference_update(_DOMAIN_TOOL_MAP["files"])
if _relevant_tools is not None:
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
@@ -24225,6 +24234,7 @@ async def stream_agent_loop(
_failed_read_recovery_sent = False
_failed_read_recovery_instruction_sent = False
_post_effectful_mutation_done = False
_verified_coding_summary_emitted = False
_successful_mutation_signatures: set[tuple[str, str]] = set()
_single_execution_bound = _request_forbids_execution_retry(_last_user)
_execution_tool_attempts: dict[str, int] = {}
@@ -25874,9 +25884,17 @@ async def stream_agent_loop(
and not _approved_result_injected
and not _native_terminal_runtime
and not normalized_external_tool_schemas
# A one-tool shortcut cannot own a causal compound workflow. Let
# the agent consume the complete request-scoped tool surface.
and len(_caller_relevant_tools or ()) <= 1
# The explicit topic-bulk path below owns its search-then-bulk
# sequence. Other multi-tool requests need the agent's full route.
and (
len(_caller_relevant_tools or ()) <= 1
or (
_caller_relevant_tools == {
"mcp__email__search_emails", "mcp__email__bulk_email",
}
and _parse_qwen_explicit_email_topic_bulk_action_request(_last_user)
)
)
and not _request_has_compound_actions(_last_user)
# Sealed safe reads use the central required-operation path so
# execution and canonical rendering have the same owner.
@@ -33949,6 +33967,11 @@ async def stream_agent_loop(
_tui_bash_block_completed
and block.tool_type == "host_shell"
)
and not (
block.tool_type == "host_shell"
and _has_tui_host_bridge
and _post_effectful_mutation_done
)
):
_terminal_summary = _ody_qwen_terminal_tool_summary({
"tool": block.tool_type,
@@ -35383,10 +35406,11 @@ async def stream_agent_loop(
_post_effectful_mutation_done
and _post_edit_verification_completed
and _workspace_mutation_completion_authorized
and _deterministic_terminal_eligible
and (_deterministic_terminal_eligible or _tui_local_execution_turn)
):
if _tui_local_execution_turn or _qwen38_tool_router:
full_response = _tui_verified_coding_summary(tool_events)
_verified_coding_summary_emitted = True
yield f'data: {json.dumps({"type": "final_response", "content": full_response})}\n\n'
elif not full_response.strip() or full_response.strip().startswith("```"):
_verification_output = ""
@@ -36970,7 +36994,7 @@ async def stream_agent_loop(
_response_before_tool_summary = full_response
_action_summary_selected = False
if tool_events and _deterministic_terminal_eligible:
if tool_events and _deterministic_terminal_eligible and not _verified_coding_summary_emitted:
_multi_read_email_summaries = _email_read_summaries_from_tool_events(tool_events)
_multi_attachment_summaries = _email_attachment_summaries_from_tool_events(tool_events)
_bulk_email_state_summary = _email_state_bulk_terminal_summary(tool_events, user_text=_last_user)
+48 -1
View File
@@ -231,6 +231,7 @@ def _wrap_workspace_namespace(
cwd: str,
*,
chdir: str = "/workspace",
interpreter_prefix: str | None = None,
) -> str | None:
"""Run a shell command with the active workspace mounted at /workspace.
@@ -254,8 +255,53 @@ def _wrap_workspace_namespace(
"--dir", "/tmp", "--tmpfs", "/tmp",
"--dev-bind", "/dev", "/dev", "--proc", "/proc",
"--dir", "/workspace", "--bind", cwd, "/workspace",
"--chdir", chdir, "/bin/bash", "-lc", content,
]
# setup-python installs interpreters under /opt, and local CI virtualenvs
# can live under /tmp. Those paths are hidden by the private root/tmpfs.
# Expose only the active interpreter environment, read-only, so Python
# tools keep their installed packages without exposing the host /tmp.
if interpreter_prefix:
prefix = os.path.abspath(interpreter_prefix)
resolved_prefix = os.path.realpath(prefix)
mounted_roots = ("/usr", "/home", "/mnt")
reserved_roots = {
"/", "/tmp", "/var", "/opt", "/etc", "/workspace",
"/root", "/run", "/proc", "/dev", "/sys", *mounted_roots,
}
already_visible = any(
prefix == root or prefix.startswith(root + os.sep)
for root in mounted_roots
)
# A prefix is trusted only when it names a specific interpreter tree.
# In particular, never overlay the private root, tmpfs, or workspace
# with a broad host directory. Reject symlinked prefixes too: bwrap
# would otherwise bind the resolved source at a different destination.
has_environment_layout = (
os.path.isfile(os.path.join(prefix, "pyvenv.cfg"))
or (
os.path.isfile(os.path.join(prefix, "bin", "python"))
and os.path.isdir(os.path.join(
prefix, "lib", f"python{sys.version_info.major}.{sys.version_info.minor}",
))
)
)
if (
not already_visible
and prefix == resolved_prefix
and prefix not in reserved_roots
and len(prefix.split(os.sep)) >= 3
and os.path.isdir(prefix)
and has_environment_layout
):
parents = []
parent = os.path.dirname(prefix)
while parent not in ("/", "/tmp", "/etc", "/workspace", *mounted_roots):
parents.append(parent)
parent = os.path.dirname(parent)
for directory in reversed(parents):
args.extend(("--dir", directory))
args.extend(("--ro-bind", prefix, prefix))
args.extend(("--chdir", chdir, "/bin/bash", "-lc", content))
return shlex.join(args)
@@ -940,6 +986,7 @@ class PythonTool:
python_command,
agent_cwd(),
chdir="/workspace",
interpreter_prefix=sys.prefix,
)
if needs_virtual_namespace
else None
+71 -14
View File
@@ -18,6 +18,7 @@ import urllib.request
from pathlib import Path
from typing import Dict, Any
from core import platform_compat
from src.constants import MAX_OUTPUT_CHARS
PDF_EXTRACT_MAX_BYTES = 80_000_000
@@ -119,6 +120,47 @@ def _browser_pid_file_candidates(
)
return list(dict.fromkeys(candidates))
# Linux exposes one command line per pid under /proc; macOS and Windows do not.
# Kept as a module attribute so the procfs-dependent paths stay testable on a
# host that has no procfs, and on one that does.
def _process_command_line(pid: int) -> str | None:
"""Command line of a running process, or ``None`` when it cannot be read.
``None`` means "this host cannot tell", not "the process is gone". Off
Linux there is no procfs to read a command line from, so callers must not
treat it as proof that the process exited.
"""
try:
return (platform_compat.PROC_ROOT / str(pid) / "cmdline").read_bytes().replace(
b"\0", b" "
).decode("utf-8", errors="replace")
except (OSError, UnicodeError):
return None
def _process_is_alive(pid: int) -> bool:
"""Whether a pid currently exists.
Delegates to ``core.platform_compat.pid_alive`` rather than probing with
``os.kill(pid, 0)`` directly. That probe is POSIX-only: CPython's Windows
``os.kill`` calls ``TerminateProcess(handle, sig)`` for any signal other
than CTRL_C / CTRL_BREAK, so it would *kill* the daemon it is asked about.
Windows is also where there is no procfs, which is precisely when this
function gets called at all.
``pid_alive`` reads False for a pid that ``os.kill`` reports with
``PermissionError`` — a live process owned by another user. Neither caller
here wants a different answer: the sweep only unlinks a pid file it wrote
itself, and treating somebody else's pid as "not our daemon" is the safe
reading in both.
"""
return platform_compat.pid_alive(pid)
_SCHOLARLY_METADATA_CUE_RE = re.compile(
r"\b(?:accept(?:ed|ance)?|publish(?:ed|ing|cation)?|venue|conference|"
r"journal|proceedings|doi)\b",
@@ -2366,8 +2408,14 @@ class PrivateBrowserTool:
except OSError:
return
profile_prefix = str(tmpdir / "agent-browser-chrome-")
if not platform_compat.has_procfs():
# Without procfs there is no way to match a reparented Chrome by
# its command line, and the sweep is an optimisation rather than a
# correctness requirement. Leave those trees to the daemon's own
# lifecycle instead of failing the whole shutdown path.
return
pids: list[int] = []
for entry in Path("/proc").iterdir():
for entry in platform_compat.PROC_ROOT.iterdir():
if not entry.name.isdigit():
continue
try:
@@ -2397,16 +2445,18 @@ class PrivateBrowserTool:
for pid_file in pid_files:
try:
pid = int(pid_file.read_text().strip())
command_line = (Path("/proc") / str(pid) / "cmdline").read_bytes().replace(
b"\0", b" "
).decode("utf-8", errors="replace")
except FileNotFoundError:
# The daemon may have exited between writing its pid file and
# this cleanup pass. The exact file is still ours to remove.
with contextlib.suppress(FileNotFoundError, PermissionError, OSError):
pid_file.unlink()
except (OSError, ValueError):
continue
except (OSError, UnicodeError, ValueError):
command_line = _process_command_line(pid)
if command_line is None:
# Either the daemon exited between writing its pid file and
# this pass, or this host has no procfs to ask. Only the first
# justifies forgetting the pid file. Without procfs we cannot
# confirm the process is ours, so we neither kill it nor drop
# the record that would let a later pass find it.
if not _process_is_alive(pid):
with contextlib.suppress(FileNotFoundError, PermissionError, OSError):
pid_file.unlink()
continue
if "agent-browser" in command_line:
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
@@ -2433,10 +2483,17 @@ class PrivateBrowserTool:
for pid_file in _browser_pid_file_candidates(runtime_dir, namespace, session_id):
try:
pid = int(pid_file.read_text().strip())
command_line = (Path("/proc") / str(pid) / "cmdline").read_bytes().replace(
b"\0", b" "
).decode("utf-8", errors="replace")
except (FileNotFoundError, OSError, UnicodeError, ValueError):
except (OSError, ValueError):
continue
command_line = _process_command_line(pid)
if command_line is None:
# Without procfs we can only tell that something with this pid
# is alive, not that it is agent-browser. The pid file is our
# own namespaced one, so treat a live pid as a match: answering
# "no daemon" here is what lets `close` bootstrap a fresh one
# and wait on its browser forever.
if _process_is_alive(pid):
return True
continue
if "agent-browser" in command_line:
return True
+3 -1
View File
@@ -17,7 +17,9 @@ def compact_browser_observation(value, budget=8000):
notices.append('Exit code: ' + str(item['exit_code']))
if item.get('success') is False:
notices.append('Browser command failed.')
snapshot = item.get('snapshot')
snapshot = item.get('snapshot') or item.get('text')
if item.get('title'):
notices.append('Title: ' + str(item['title']))
url = item.get('url') or item.get('origin')
if isinstance(snapshot, str) and snapshot.strip():
# Snapshot text already contains labels and refs in DOM order.
+4 -2
View File
@@ -2319,7 +2319,7 @@ def compact_schemas(schemas, *, model=None):
'Saved notes. Create a todo in ONE add call: note_type="checklist", '
'checklist_items=[{text,done:false}], title only if requested (otherwise auto-dated). '
'Keep tasks and stated times in item text, never title. No time conversion. '
'Freeform body: content. Existing note: update+id, never add. '
'Freeform body: content. add creates a new note; update with id edits an existing note, never add. '
'list supports label/archived; search by topic; view by id. Delete only on request. '
'due_date sets a reminder, not an item time.'
)
@@ -5339,6 +5339,8 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
required_artifacts = runtime_required_artifacts(
direct_user_text, client_runtime_context,
) if native_workspace_enabled else tuple()
if required_artifacts:
web_briefing_target = False
yield event({'type': 'turn_contract', **turn_contract.audit(), 'schema_mode': 'compact_contract_v5',
'native_workspace': native_workspace_enabled,
'required_artifacts': list(required_artifacts),
@@ -7186,7 +7188,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
browser_access_blocked = (
canonical(actual_tool) == 'private_browser'
and not failed
and browser_observation_access_blocked(output)
and browser_observation_access_blocked(result.get('output') or result)
)
browser_page_missing = (
canonical(actual_tool) == 'private_browser'
+10
View File
@@ -140,6 +140,16 @@ LLM_HOSTS = [h.strip() for h in os.getenv("LLM_HOSTS", "").split(",") if h.strip
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
SEARXNG_INSTANCE = os.getenv("SEARXNG_INSTANCE", "http://localhost:8080")
# Scholarly title resolution. These are the only third-party metadata APIs the
# search path calls directly, so they get named constants rather than literals
# repeated at each call site. The budget bounds the whole SearXNG -> OpenAlex ->
# arXiv chain: each hop used to get its own full timeout, so one scholarly query
# could stall a user-facing search for the sum of all three.
ARXIV_API_URL = "https://export.arxiv.org/api/query"
OPENALEX_API_URL = "https://api.openalex.org/works"
SCHOLARLY_LOOKUP_TIMEOUT = 12.0
SCHOLARLY_LOOKUP_TOTAL_BUDGET = 20.0
# Cleanup configuration
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true"
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
+4 -1
View File
@@ -38,7 +38,10 @@ def discover_tailscale_hosts() -> List[str]:
global _hosts_cache, _hosts_cache_time
now = time.time()
if _hosts_cache and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL:
# Gate on the timestamp, not the list: a successful query that found no
# eligible peers is a real answer, and testing the list's truthiness made
# that case re-run `tailscale status` (up to a 5s timeout) on every call.
if _hosts_cache_time and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL:
return list(_hosts_cache)
hosts = []
+15 -4
View File
@@ -97,19 +97,30 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
pending = fut
owner = True
if not owner:
return await pending
# A cancelled waiter must not cancel the shared Future for the owner
# and every other waiter.
return await asyncio.shield(pending)
try:
val = await fetch()
async with _shared_cache_lock:
_shared_cache[key] = (time.monotonic() + ttl, val)
_shared_cache_pending.pop(key, None)
pending.set_result(val)
return val
except asyncio.CancelledError:
# Cancellation is a BaseException on supported Python versions, so it
# bypasses the Exception handler below. Wake all current waiters while
# allowing a later caller to retry the fetch.
pending.cancel()
raise
except Exception as e:
async with _shared_cache_lock:
_shared_cache_pending.pop(key, None)
pending.set_exception(e)
raise
finally:
# Keep this cleanup synchronous so a second cancellation cannot
# interrupt it and leave a permanently pending Future behind. All
# access runs on the scheduler's event-loop thread.
if _shared_cache_pending.get(key) is pending:
_shared_cache_pending.pop(key, None)
def compute_next_run(schedule: str, scheduled_time: str,
+1 -1
View File
@@ -101,7 +101,7 @@ _register(
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
)
_register(
{"private_browser", "web_search", "youtube_tool"},
{"get_weather", "private_browser", "web_search", "youtube_tool"},
ToolEffect.BROKERED_NETWORK_READ,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
-1
View File
@@ -369,7 +369,6 @@ FUNCTION_TOOL_SCHEMAS = [
"full": {"type": "boolean", "description": "Raise the download budget to the hard cap for large pages/files. Use only after a result reported partial content."},
"query": {"type": "string", "description": "Optional comma-separated terms used to select matching passages/pages from long documents or PDFs, for example 'DocVQA, ChartQA, TextVQA, Qwen2.5-VL-72B'."}
},
"anyOf": [{"required": ["url"]}, {"required": ["urls"]}],
"required": []
}
}
+12 -7
View File
@@ -17,6 +17,7 @@ import re
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from core import platform_compat
from routes._validators import validate_remote_host, validate_ssh_port
from src.tools._common import _parse_tool_args
@@ -676,17 +677,17 @@ def _scan_running_model_processes() -> List[Dict[str, Any]]:
a dict shaped like a cookbook task so the caller can merge cleanly.
"""
import os
if not os.path.isdir("/proc"):
if not platform_compat.has_procfs():
return []
proc_root = platform_compat.PROC_ROOT
out: List[Dict[str, Any]] = []
seen_keys = set()
try:
for pid_dir in os.listdir("/proc"):
for pid_dir in os.listdir(proc_root):
if not pid_dir.isdigit():
continue
try:
with open(f"/proc/{pid_dir}/cmdline", "rb") as f:
raw = f.read()
raw = (proc_root / pid_dir / "cmdline").read_bytes()
except (OSError, PermissionError):
continue
if not raw:
@@ -1124,12 +1125,16 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
import signal
tracked_cmd = str((matched.get("payload") or {}).get("_cmd") or "").strip()
matched_pids: list[int] = []
if tracked_cmd:
for pid_name in os.listdir("/proc"):
# No procfs means no way to match a survivor by its command line.
# The tmux kill above already stopped the session, so skip the
# sweep instead of failing a stop that worked.
if tracked_cmd and platform_compat.has_procfs():
proc_root = platform_compat.PROC_ROOT
for pid_name in os.listdir(proc_root):
if not pid_name.isdigit() or int(pid_name) == os.getpid():
continue
try:
raw = open(f"/proc/{pid_name}/cmdline", "rb").read()
raw = (proc_root / pid_name / "cmdline").read_bytes()
process_cmd = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip()
except (OSError, PermissionError):
continue
+13 -10
View File
@@ -955,6 +955,15 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
# Content words such as "reviews", "which", "highlights", or
# "final" must not become a public-Web lookup operation.
return None
if re.fullmatch(
_REQUEST_PREFIX + r"(?:which\s+search\s+(?:backend|provider)\s+am\s+i\s+on"
r"(?:\s+right\s+now)?|what\s+(?:default\s+)?time\s+filter\s+is\s+"
r"my\s+search\s+set\s+to(?:\s+by\s+default)?|show\s+me\s+the\s+whole\s+"
r"search\s+(?:settings?\s+)?group)[?!.]*",
text,
re.I,
):
return frozenset({"manage_settings"})
web_lookup_fallback = False
if (
re.search(r"\b(?:look\s*up|search|find)\b", text, re.I)
@@ -969,6 +978,7 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
text,
re.I,
)
and not re.search(r"\b(?:inbox|emails?|mails?|calendar|meetings?|my\s+notes?)\b", text, re.I)
):
# Current lookups need discovery before navigation. Letting the model
# begin on an arbitrary browser page can ground an answer in stale or
@@ -983,7 +993,7 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
r"compare|pros?|cons?|opinions?|thoughts?|about)\b",
text,
re.I,
):
) and not re.search(r"\b(?:inbox|emails?|mails?|calendar|meetings?|my\s+notes?)\b", text, re.I):
# Product/service review requests are current public-web lookups even
# when the user does not say "search". Route them to web_search before
# the model sees a schema; otherwise a no-tool contract invites raw
@@ -1145,15 +1155,6 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
re.I,
):
return frozenset({"web_search"})
if re.fullmatch(
_REQUEST_PREFIX + r"(?:which\s+search\s+(?:backend|provider)\s+am\s+i\s+on"
r"(?:\s+right\s+now)?|what\s+(?:default\s+)?time\s+filter\s+is\s+"
r"my\s+search\s+set\s+to(?:\s+by\s+default)?|show\s+me\s+the\s+whole\s+"
r"search\s+(?:settings?\s+)?group)[?!.]*",
text,
re.I,
):
return frozenset({"manage_settings"})
if re.fullmatch(
_REQUEST_PREFIX + r"(?:is\s+there\s+)?anything\s+new\s+(?:in|on|about)\s+"
r"[^?!.]{2,160}\b(?:today|this\s+(?:week|month|year)|recently)[?!.]*",
@@ -4572,6 +4573,8 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
established_family = immediately_established_family(text, history)
if established_family and not newly_named_families:
return frozenset({established_family})
if selected_tools_for_request(raw_text) == frozenset({"manage_settings"}):
return frozenset({"cookbook_admin"})
concrete_urls = re.findall(r"\bhttps?://[^\s<>\"']+", raw_text, re.I)
workspace_media = re.search(
r"(?:file://)?/workspace/[^\s`\"']+\."
+48
View File
@@ -16,6 +16,12 @@ break the primary use case. What it *always* rejects:
For exposed multi-tenant deployments, set ``EMBEDDING_BLOCK_PRIVATE_IPS=true`` to
additionally reject all private and loopback targets (full SSRF lockdown).
On a DNS64/NAT64 network an IPv4-only host resolves to the RFC 6052 Well-Known
Prefix ``64:ff9b::/96``. Such an address is decoded to the IPv4 destination the
translator will actually contact, and that destination is then judged under the
strict policy — so the prefix reaches public IPv4 but never tunnels to loopback,
private, shared or link-local space.
"""
import ipaddress
@@ -33,6 +39,39 @@ ALLOWED_SCHEMES = ("http", "https")
# versions for other special ranges.
_SHARED_ADDRESS_SPACE_V4 = ipaddress.ip_network("100.64.0.0/10")
# RFC 6052 §2.1 Well-Known Prefix for IPv4/IPv6 address translation (NAT64).
# An address inside exactly this /96 is not a destination in its own right: the
# low 32 bits carry the IPv4 address the translator will actually contact. On a
# DNS64/NAT64 network every public IPv4-only host resolves this way, so judging
# the outer IPv6 (which CPython reports as ``is_reserved``) would reject the
# whole public internet while telling us nothing about the real target.
#
# RFC 6052 §3.1 allows the Well-Known Prefix to represent *only* globally
# routable IPv4. The embedded destination is therefore always evaluated under
# the strict policy, whatever ``block_private`` the caller passed: the prefix
# must never become a path to loopback, private, shared, link-local, multicast,
# unspecified or otherwise non-global space.
#
# Deliberately exact. Network-specific prefixes carry locally assigned meaning
# and are NOT decoded here — notably 64:ff9b:1::/48 (RFC 8215), which this /96
# membership test excludes and which stays rejected as reserved.
_NAT64_WELL_KNOWN_PREFIX_V6 = ipaddress.ip_network("64:ff9b::/96")
def _nat64_well_known_embedded_ipv4(
ip: ipaddress._BaseAddress,
) -> Optional[ipaddress.IPv4Address]:
"""Return the IPv4 target embedded in an RFC 6052 Well-Known-Prefix address.
``None`` when ``ip`` is not inside ``64:ff9b::/96``, i.e. when no IPv4
destination may be inferred from it.
"""
if not isinstance(ip, ipaddress.IPv6Address):
return None
if ip not in _NAT64_WELL_KNOWN_PREFIX_V6:
return None
return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
def _default_resolver(host: str) -> List[str]:
"""Resolve a hostname to the list of IP strings it maps to (A + AAAA)."""
@@ -44,6 +83,15 @@ def _classify(ip: ipaddress._BaseAddress, *, block_private: bool) -> Optional[st
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — judge the embedded v4.
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
else:
# RFC 6052 Well-Known Prefix — judge the IPv4 destination the NAT64
# translator will contact, always under the strict policy.
translated = _nat64_well_known_embedded_ipv4(ip)
if translated is not None:
reason = _classify(translated, block_private=True)
if reason:
return f"NAT64 translated destination blocked: {reason}"
return None
if ip.is_link_local:
return f"link-local address blocked (SSRF metadata risk): {ip}"
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
+1 -1
View File
@@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
import calendarModule from './js/calendar.js?v=20260914emailsource11';
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
import adminModule from './js/admin.js?v=20260921chatgptusage2';
import adminModule from './js/admin.js?v=20260929csssplit';
import settingsModule from './js/settings.js?v=20260912writingstyle3';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js';
+183
View File
@@ -0,0 +1,183 @@
/* 00-tokens - part of the former static/style.css.
*
* The stylesheet is split by area, but the split is mechanical: rules were
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
* that order is what preserves the cascade. A rule sits in the file its
* original position fell into, so an area's rules are not all in its file and
* a file is not only that area's rules.
*
* Do not reorder the <link> tags in static/index.html, and do not move a rule
* between files: with 1,151 redeclarations and 1,886 !important in this
* cascade, either can change which declaration wins.
*
* static/index.html and the PRECACHE list in static/sw.js must agree on this
* order. tests/test_static_stylesheet_manifest.py and
* tests/test_css_computed_style_snapshot.py fail if that drifts.
*/
/* ============================================ */
/* Odysseus UI — Consolidated Stylesheet */
/* ============================================ */
/* ── Variables ──
*
* Theme-public variables (override via theme.js or custom themes):
* Core: --bg, --fg, --panel, --border, --red
* Syntax: --hl-keyword, --hl-string, --hl-comment, --hl-function,
* --hl-number, --hl-builtin, --hl-variable, --hl-params,
* --hl-bg, --hl-fg
* Accents: --accent-primary, --accent-error (set by theme.js)
* Semantic: --color-error, --color-success, --color-warning, --color-danger,
* --color-accent, --color-muted, --color-muted-alt
*/
:root {
/* Core palette */
--bg: #282c34;
--fg: #9cdef2;
--panel: #111;
--border: #355a66;
--red: #e06c75;
/* Were `var(--green)` / `var(--warn)` — self-referential, so they
resolved to invalid and every site fell back to its own literal
(or, for sites with no fallback, painted as transparent/inherit).
Anchor them to real hex so the token layer actually works. */
--green: #50fa7b;
--warn: #f0ad4e;
/* Syntax highlighting */
--hl-bg: #1e2228;
--hl-fg: #9cdef2;
--hl-keyword: #c678dd;
--hl-string: #e5c07b;
--hl-comment: #828997;
--hl-function: #61afef;
--hl-number: #d19a66;
--hl-builtin: #56b6c2;
--hl-variable: #abb2bf;
--hl-params: #a8c0d4;
/* Semantic colors */
--color-error: #ff4444;
--color-error-light: #ff6666;
--color-success: #4caf50;
--color-warning: #f0ad4e;
--color-danger: #c0392b;
--color-recording: #ff3b30;
--color-recording-hover: #d63031;
--color-muted: #888;
--color-muted-alt: #6b7280;
--color-accent: #00aaff;
--color-agent-active: #00ff00;
--color-brand-blue: #3b82f6;
--color-blind-orange: #ff9800;
--color-save-green: var(--color-success);
--color-link-hover: #66c7ff;
--color-subheader: #6b8a94;
--select-bg: var(--bg);
--select-fg: var(--fg);
--select-option-bg: color-mix(in srgb, var(--panel) 74%, var(--bg));
--select-option-fg: var(--fg);
--select-option-active-bg: color-mix(in srgb, var(--accent, var(--red)) 24%, var(--panel));
/* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so
themes can override without touching the goal CSS. */
--accent-warm: #d19a66;
}
:root.light {
--bg: #f5f5f5;
--fg: #2b2b2b;
--panel: #fff;
--border: #bbb;
--hl-bg: #f9f9f9;
--hl-fg: #2b2b2b;
--hl-keyword: #7928a1;
--hl-string: #986801;
--hl-comment: #6a737d;
--hl-function: #005cc5;
--hl-number: #986801;
--hl-builtin: #0070a0;
--hl-variable: #383a42;
--hl-params: #4a4f5c;
--select-bg: #eaeaea;
--select-fg: var(--fg);
--select-option-bg: var(--panel);
--select-option-fg: var(--fg);
--select-option-active-bg: color-mix(in srgb, var(--red) 16%, var(--panel));
}
/* ── Reset & Base ── */
* { box-sizing: border-box; }
html, body { overflow-x: hidden; height: 100%; margin: 0; overscroll-behavior: none; }
body {
background-color: var(--bg);
color: var(--fg);
/* Keep the base padding transition for older layout paths that still adjust
the body directly. Edge docks reserve workspace room on the flex panes
below so left + right docks can coexist without skewing the whole body. */
transition: padding-left 160ms cubic-bezier(0.22, 0.61, 0.36, 1),
padding-right 160ms cubic-bezier(0.22, 0.61, 0.36, 1);
font-family: var(--font-family, 'Fira Code', monospace);
display: flex;
height: 100%;
height: 100dvh; /* dynamic viewport height — adapts when mobile keyboard opens */
overflow: hidden;
}
/* Self-hosted Fira Code font */
@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* GohuFont is the default UI face across themes; keep the shipped font local
so the default is available before custom-font discovery completes. */
@font-face { font-family: 'GohuFont'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/custom/GohuFont.ttf') format('truetype'); }
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Code block baseline */
pre, code, .hljs {
font-size: 11px;
line-height: 1.5;
}
/* Scrollbar styling */
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--panel);
}
::-webkit-scrollbar-thumb {
background-color: var(--red);
border-radius: 4px;
border: 2px solid var(--panel);
}
::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--red) 80%, white);
}
}
html {
scrollbar-color: var(--red) var(--panel);
scrollbar-width: thin;
}
/* Utility */
.red-text { color: var(--red); }
/* ── Density Overrides ── */
:root.density-compact { font-size: 13px; }
:root.density-compact .msg { padding: 6px 10px; margin-bottom: 4px; }
:root.density-compact .list-item { padding: 4px 8px; }
:root.density-compact .sidebar .section { padding: 0; }
:root.density-spacious { font-size: 16px; }
:root.density-spacious .msg { padding: 14px 18px; margin-bottom: 12px; }
:root.density-spacious .list-item { padding: 8px 12px; }
:root.density-spacious .sidebar .section { padding: 0; }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+839
View File
@@ -0,0 +1,839 @@
/* 12-gallery - part of the former static/style.css.
*
* The stylesheet is split by area, but the split is mechanical: rules were
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
* that order is what preserves the cascade. A rule sits in the file its
* original position fell into, so an area's rules are not all in its file and
* a file is not only that area's rules.
*
* Do not reorder the <link> tags in static/index.html, and do not move a rule
* between files: with 1,151 redeclarations and 1,886 !important in this
* cascade, either can change which declaration wins.
*
* static/index.html and the PRECACHE list in static/sw.js must agree on this
* order. tests/test_static_stylesheet_manifest.py and
* tests/test_css_computed_style_snapshot.py fail if that drifts.
*/
.research-synapse.rs-phase-reading .rs-phase-chip,
.research-synapse.rs-phase-analyzing .rs-phase-chip { color: var(--gold, #d4a72c); }
.research-synapse.rs-phase-writing .rs-phase-chip { color: var(--color-success, #4caf50); }
@media (prefers-reduced-motion: reduce) {
.research-synapse .rs-live-dot,
.research-synapse .rs-pulse,
.research-synapse .rs-node-new,
.research-synapse .rs-node-expanded,
.research-synapse .rs-edge-firing { animation: none !important; }
}
/* ── Raw findings items (inside sources-style box) ── */
.finding-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.finding-item .source-link {
border-radius: 6px 6px 0 0;
}
.finding-summary {
padding: 6px 8px 8px;
font-size: 0.82em;
line-height: 1.5;
color: var(--fg-dim, var(--fg));
opacity: 0.85;
border-bottom: 1px solid color-mix(in srgb, var(--fg) 6%, transparent);
margin-bottom: 4px;
white-space: pre-wrap;
word-break: break-word;
}
.finding-item:last-child .finding-summary {
border-bottom: none;
margin-bottom: 0;
}
/* ═══════════════════════════════════════════════════════════
Gallery Editor
═══════════════════════════════════════════════════════════ */
/* Tab bar */
.gallery-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
padding: 0 16px;
background: var(--panel);
}
.gallery-tab {
padding: 8px 18px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--fg);
opacity: 0.6;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: opacity 0.15s, border-color 0.15s;
}
.gallery-tab:hover { opacity: 0.85; }
.gallery-tab.active {
opacity: 1;
border-bottom-color: var(--red);
}
/* Narrow modal tab strips should stay on one row. Resized docked windows can
be much narrower than the viewport, so this cannot live only in mobile media
queries. */
.cookbook-tabs,
.memory-tabs,
.admin-tabs,
.lib-tabs,
.gallery-tabs,
.preset-tabs {
flex-wrap: nowrap !important;
overflow-x: auto !important;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.cookbook-tabs::-webkit-scrollbar,
.memory-tabs::-webkit-scrollbar,
.admin-tabs::-webkit-scrollbar,
.lib-tabs::-webkit-scrollbar,
.gallery-tabs::-webkit-scrollbar,
.preset-tabs::-webkit-scrollbar {
display: none;
}
.cookbook-tabs > *,
.memory-tabs > *,
.admin-tabs > *,
.lib-tabs > *,
.gallery-tabs > *,
.preset-tabs > * {
flex: 0 0 auto;
}
.cookbook-tab,
.memory-tab,
.admin-tab,
.lib-tab,
.gallery-tab,
.preset-tab {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
line-height: 1;
}
.gallery-tab {
gap: 6px;
}
@container (max-width: 360px) {
.cookbook-tab:has(svg),
.memory-tab:has(svg),
.admin-tab:has(svg),
.lib-tab:has(svg),
.gallery-tab:has(svg),
.preset-tab:has(svg) {
width: 34px;
min-width: 34px;
padding-left: 0;
padding-right: 0;
font-size: 0;
}
.cookbook-tab:has(svg) svg,
.memory-tab:has(svg) svg,
.admin-tab:has(svg) svg,
.lib-tab:has(svg) svg,
.gallery-tab:has(svg) svg,
.preset-tab:has(svg) svg {
width: 14px;
height: 14px;
margin-right: 0 !important;
vertical-align: middle !important;
}
.memory-tab:has(svg) .memory-count,
.gallery-tab:has(svg) .gallery-tab-label,
.gallery-tab:has(svg) .gallery-tab-close,
.cookbook-tab:has(svg) .cookbook-tab-count,
.preset-tab:has(svg) .preset-count {
display: none !important;
}
}
/* Icon + label layout inside each tab. */
.gallery-tab {
display: inline-flex;
align-items: center;
gap: 6px;
}
.gallery-tab-icon {
display: inline-flex;
align-items: center;
opacity: 0.85;
}
.gallery-tab.active .gallery-tab-icon { opacity: 1; }
/* Close × on the Edit tab — appears on hover. */
.gallery-tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 2px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
/* Use a slightly larger glyph at a larger line-height so the × sits
visually centered — the U+00D7 character has uneven em-box metrics. */
font-size: 16px;
line-height: 16px;
font-weight: 500;
cursor: pointer;
padding-bottom: 2px;
box-sizing: border-box;
}
.gallery-tab:hover .gallery-tab-close,
.gallery-tab.active .gallery-tab-close { opacity: 0.65; }
/* Hide the close × entirely on the Edit tab when there's no edit open. */
.gallery-tab[data-tab="editor"]:not(.has-edit) .gallery-tab-close { display: none !important; }
.gallery-tab-close:hover { opacity: 1 !important; background: color-mix(in srgb, var(--red) 25%, transparent); color: var(--red); }
/* Inline rename input shown when the Edit tab is double-clicked. */
.gallery-tab-rename-input {
background: var(--bg);
border: 1px solid var(--red);
border-radius: 3px;
color: var(--fg);
font: inherit;
font-size: 13px;
padding: 1px 4px;
width: 140px;
outline: none;
}
/* Albums tab — grid of album cards with cover thumbnails. */
.gallery-albums-container {
padding: 12px 4px;
max-height: calc(92vh - 120px);
overflow-y: auto;
border: 2px dashed transparent;
border-radius: 8px;
transition: border-color 0.15s, background 0.15s;
}
.gallery-settings-container {
padding: 8px 4px;
max-height: calc(92vh - 120px);
overflow-y: auto;
}
.gallery-albums-container.gallery-dragover {
border-color: var(--red);
background: color-mix(in srgb, var(--red) 5%, transparent);
}
.gallery-albums-empty {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 48px 16px;
opacity: 0.7;
font-size: 13px;
}
.gallery-albums-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.gallery-album-card {
position: relative;
display: flex;
flex-direction: column;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.12s, border-color 0.12s, box-shadow 0.12s;
}
.gallery-album-card:hover {
transform: translateY(-2px);
border-color: var(--red);
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 12%, transparent);
}
.gallery-album-menu-btn {
position: absolute;
top: 6px; right: 6px;
width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--bg) 70%, transparent);
color: var(--fg);
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
z-index: 2;
backdrop-filter: blur(4px);
/* Plain "…" text — the previous SVG was rendering as a flat black
block on some themes when its currentColor didn't contrast. */
font-size: 16px;
line-height: 1;
font-weight: 600;
padding: 0 0 4px;
}
.gallery-album-card:hover .gallery-album-menu-btn,
.gallery-album-menu-btn:focus-visible { opacity: 1; }
.gallery-album-menu-btn:hover { background: var(--bg); }
.gallery-album-menu-pop {
position: absolute;
top: 34px; right: 6px;
min-width: 140px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 18%, transparent);
display: flex; flex-direction: column;
padding: 4px;
z-index: 3;
}
.gallery-album-menu-pop[hidden] { display: none; }
.gallery-album-cover {
aspect-ratio: 4 / 3;
background: color-mix(in srgb, var(--fg) 5%, var(--bg));
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
.gallery-album-placeholder {
opacity: 0.4;
font-size: 40px;
display: flex; align-items: center; justify-content: center;
width: 100%; height: 100%;
}
.gallery-album-info {
padding: 8px 10px;
display: flex; flex-direction: column; gap: 2px;
}
.gallery-album-name {
font-size: 13px;
font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gallery-album-count {
font-size: 11px;
opacity: 0.55;
}
.gallery-album-card-add {
border-style: dashed;
opacity: 0.7;
}
.gallery-album-card-add:hover { opacity: 1; }
.gallery-album-action-tile {
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
}
/* Edit-tab empty state — shown before any image is loaded. */
.gallery-editor-landing {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px;
padding: 28px 16px;
flex: 1;
text-align: center;
color: var(--fg);
}
.gallery-editor-landing h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.gallery-editor-heading {
display: inline-flex;
align-items: center;
gap: 7px;
}
.gallery-editor-heading > svg { opacity: 0.6; flex: 0 0 auto; }
/* "vision model" link in the AI-tagging description → opens Settings. */
.ge-vision-link {
color: var(--accent, var(--red));
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.ge-vision-link:hover { opacity: 0.8; }
.ge-alpha-tag {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
vertical-align: middle;
padding: 1px 5px;
border-radius: 4px;
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 15%, transparent);
position: relative;
top: -1px;
}
.gallery-editor-landing-actions {
display: flex; gap: 10px;
margin-top: 8px;
}
/* Bigger primary action buttons specifically inside the editor landing —
the gallery-wide .gallery-select-btn is a compact toolbar style which is
too small for the empty-state hero. */
.gallery-editor-landing-actions .gallery-select-btn {
padding: 7px 22px 17px;
font-size: 13px;
opacity: 0.85;
}
/* Visible template picker: every canvas ratio is inspectable without opening
a native menu, and its SVG outline previews the resulting canvas shape. */
.gallery-editor-template-picker {
width: min(760px, 96%);
margin-top: 10px;
}
.gallery-editor-template-picker h4 {
margin: 0 0 7px;
font-size: 11px;
opacity: 0.55;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.gallery-editor-template-options {
display: grid;
grid-template-columns: repeat(4, minmax(105px, 1fr));
gap: 6px;
}
.gallery-editor-template-option {
height: 72px;
min-width: 0;
padding: 6px 7px;
display: grid;
grid-template-rows: 28px auto auto;
justify-items: center;
align-items: center;
background: var(--panel);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 5px;
font: inherit;
font-size: 11px;
cursor: pointer;
overflow: hidden;
}
.gallery-editor-template-option svg { color: var(--accent, var(--red)); }
.gallery-editor-template-option:hover,
.gallery-editor-template-option:focus-visible {
outline: none;
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 7%, var(--panel));
}
.gallery-editor-template-option span {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.gallery-editor-template-option small {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
opacity: 0.52;
}
@media (max-width: 620px) {
.gallery-editor-landing {
justify-content: flex-start;
gap: 7px;
padding: 18px 12px;
overflow-y: auto;
}
.gallery-editor-landing-actions {
width: 100%;
gap: 6px;
justify-content: center;
}
.gallery-editor-landing-actions .gallery-select-btn {
flex: 1 1 0;
min-width: 0;
max-width: 180px;
padding: 6px 8px 10px;
font-size: 11px;
}
.gallery-editor-template-picker {
width: 100%;
margin-top: 4px;
}
.gallery-editor-template-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* The common square, widescreen, portrait, and Instagram presets are
enough to start on a phone. Keep the print and 4K presets on desktop. */
.gallery-editor-template-option:nth-child(n + 5) {
display: none;
}
}
/* Saved-drafts grid on the editor landing. Surfaces every persisted
in-progress project so the user can resume without re-opening the
original photo. Each card: thumbnail + name + last-saved hint + ×. */
.gallery-editor-drafts {
width: min(720px, 92%);
margin: 28px auto 0; /* centered */
padding-top: 18px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 10px;
}
.gallery-editor-drafts {
position: relative;
}
.gallery-editor-drafts-loading {
position: absolute;
inset: 30px 0 0 0;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
background: var(--panel);
border-radius: 6px;
min-height: 80px;
}
.gallery-editor-drafts-header {
display: flex;
align-items: center;
justify-content: center; /* center the title / search / select */
gap: 8px;
margin-bottom: 8px;
}
.gallery-editor-drafts-title {
margin: 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.55;
font-weight: 600;
text-align: left;
flex-shrink: 0;
}
.gallery-editor-drafts-search {
flex: 1 1 auto;
min-width: 0;
max-width: none;
height: 26px; /* same thickness as the Select button */
box-sizing: border-box;
padding: 0 8px;
font-size: 12px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
}
.gallery-editor-drafts-search:focus { outline: none; border-color: var(--red); }
/* The shared .gallery-select-btn has a -4px top margin + tall asymmetric padding
meant for the main gallery toolbar; in this header it makes the button a
different height / offset from the search box. Normalize so it lines up. */
.gallery-editor-drafts-header .gallery-select-btn {
margin-top: 0 !important;
height: 26px; /* match the search bar */
box-sizing: border-box;
padding: 0 10px;
font-size: 12px;
line-height: 1;
flex-shrink: 0;
}
.cookbook-official-filter {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
color: var(--fg);
font-size: 10px;
opacity: 0.72;
white-space: nowrap;
position: relative;
top: 1px;
}
.cookbook-official-filter > span:first-child {
position: relative;
top: 1px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 9px;
}
.cookbook-official-filter .toggle {
width: 26px;
height: 14px;
}
.cookbook-official-filter .toggle .slider:before {
width: 10px;
height: 10px;
left: 2px;
bottom: 2px;
}
.cookbook-official-filter .toggle input:checked + .slider:before {
transform: translateX(12px);
}
#gallery-editor-drafts-select { margin-left: auto; flex-shrink: 0; }
#gallery-editor-drafts-select.active {
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
}
/* Graceful exit when a project card is deleted — fade + shrink before re-render. */
.gallery-editor-draft-card.gallery-draft-removing {
opacity: 0;
transform: scale(0.92);
transition: opacity 0.24s ease, transform 0.24s ease;
pointer-events: none;
}
/* Draft select dot uses the standard small .gallery-select-dot look (same as the
photo grid) — no oversized white-ring checkbox. */
.gallery-editor-drafts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
.gallery-drafts-just-opened > .gallery-editor-draft-card {
animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards;
}
.gallery-drafts-just-opened > :nth-child(1) { animation-delay: 0.02s; }
.gallery-drafts-just-opened > :nth-child(2) { animation-delay: 0.04s; }
.gallery-drafts-just-opened > :nth-child(3) { animation-delay: 0.06s; }
.gallery-drafts-just-opened > :nth-child(4) { animation-delay: 0.08s; }
.gallery-drafts-just-opened > :nth-child(5) { animation-delay: 0.10s; }
.gallery-drafts-just-opened > :nth-child(6) { animation-delay: 0.12s; }
.gallery-drafts-just-opened > :nth-child(7) { animation-delay: 0.14s; }
.gallery-drafts-just-opened > :nth-child(8) { animation-delay: 0.16s; }
.gallery-drafts-just-opened > :nth-child(9) { animation-delay: 0.18s; }
.gallery-drafts-just-opened > :nth-child(10) { animation-delay: 0.20s; }
.gallery-drafts-just-opened > :nth-child(11) { animation-delay: 0.22s; }
.gallery-drafts-just-opened > :nth-child(12) { animation-delay: 0.24s; }
.gallery-drafts-just-opened > :nth-child(13) { animation-delay: 0.26s; }
.gallery-drafts-just-opened > :nth-child(14) { animation-delay: 0.28s; }
.gallery-drafts-just-opened > :nth-child(15) { animation-delay: 0.30s; }
.gallery-drafts-just-opened > :nth-child(16) { animation-delay: 0.32s; }
.gallery-drafts-just-opened > :nth-child(17) { animation-delay: 0.34s; }
.gallery-drafts-just-opened > :nth-child(18) { animation-delay: 0.36s; }
.gallery-drafts-just-opened > :nth-child(19) { animation-delay: 0.38s; }
.gallery-drafts-just-opened > :nth-child(20) { animation-delay: 0.40s; }
.gallery-drafts-just-opened > :nth-child(n+21) { animation-delay: 0.42s; }
.gallery-drafts-view-toggle {
display: inline-flex;
position: relative;
top: 0;
height: 24px;
flex: 0 0 auto;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
}
.gallery-drafts-view-toggle button {
width: 25px;
height: 22px;
margin: 0;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-right: 1px solid var(--border);
background: transparent;
color: color-mix(in srgb, var(--fg) 55%, transparent);
cursor: pointer;
line-height: 0;
}
.gallery-drafts-view-toggle button:last-child { border-right: 0; }
.gallery-drafts-view-toggle button:hover { color: var(--fg); }
.gallery-drafts-view-toggle button.active {
height: 22px;
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-thumb {
width: 44px;
height: 44px;
aspect-ratio: auto;
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete {
position: static;
opacity: 0.55;
background: transparent;
color: var(--fg);
}
.gallery-editor-draft-delete-trash { display: none; }
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete-trash {
display: block;
width: 18px;
height: 18px;
position: relative;
top: -2px;
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete:hover {
opacity: 1;
color: var(--color-error, #f44);
background: color-mix(in srgb, var(--color-error, #f44) 10%, transparent);
}
.gallery-editor-draft-card {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 70%, transparent);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.15s;
text-align: left;
}
.gallery-editor-draft-card:hover {
border-color: var(--accent, var(--red));
background: color-mix(in srgb, var(--panel) 90%, transparent);
}
.gallery-editor-draft-thumb {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
background: var(--bg);
display: block;
}
.gallery-editor-draft-thumb-empty {
background: repeating-linear-gradient(
45deg,
color-mix(in srgb, var(--fg) 4%, transparent),
color-mix(in srgb, var(--fg) 4%, transparent) 6px,
transparent 6px,
transparent 12px
);
}
.gallery-editor-draft-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.gallery-editor-draft-name {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-editor-draft-meta {
font-size: 10px;
opacity: 0.55;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-editor-draft-delete {
position: absolute;
top: -2px;
right: 8px;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
}
.gallery-editor-draft-delete {
padding-bottom: 2px;
}
.gallery-editor-draft-delete:hover { background: var(--red); }
/* On very narrow phones, give project search its own row instead of
compressing the title and view controls into an overflowing header. */
@media (max-width: 360px) {
.gallery-editor-drafts-header {
flex-wrap: wrap;
justify-content: flex-start;
gap: 6px;
}
.gallery-editor-drafts-title {
flex: 1 1 auto;
}
.gallery-editor-drafts-search {
order: 10;
flex: 1 1 100%;
width: 100%;
max-width: none;
}
#gallery-editor-drafts-select {
margin-left: 0;
}
}
/* Editor layout */
.gallery-editor {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
/* Prevent the editor's own scroll/touch from chaining out to the
gallery modal or the page body — critical on mobile so touching
the canvas doesn't slide the whole page. */
overscroll-behavior: contain;
touch-action: pan-y;
}
/* Pin the topbar to the top of the editor so its action buttons remain
visible if the gallery modal body itself ends up scrolling. */
.ge-topbar { position: sticky; top: 0; z-index: 5; }
/* Top bar */
.ge-topbar {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 8px;
}
.ge-topbar-left, .ge-topbar-right {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
}
.ge-topbar-right > * {
flex-shrink: 0;
}
.ge-topbar .dropdown[popover] {
position: fixed;
margin: 0;
bottom: auto;
max-height: calc(100vh - 80px);
overflow-y: auto;
}
.ge-draft-status {
flex: 0 1 auto;
min-width: 0;
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: color-mix(in srgb, var(--fg) 52%, transparent);
font-size: 10px;
line-height: 1;
transition: color 0.15s, opacity 0.15s;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+980
View File
@@ -0,0 +1,980 @@
/* Document library and editor, image gallery, image editor.
*
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
* former static/style.css now lives in the numbered 00-17 fragments; these
* three pre-existing panel files follow those fragments without changing
* their relative order. Nothing here is lazy-loaded.
*
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
* in static/sw.js must agree:
* 1. static/css/00-tokens.css through static/css/17-research.css,
* in numeric order
* 2. static/css/documents-gallery-editor.css
* 3. static/css/email-calendar-notes-tasks.css
* 4. static/css/cookbook-research-memory-settings.css
*
* Rules in this file retain their existing cascade position. Shared rules
* formerly kept in static/style.css now live in the numbered fragments.
*/
/* Document library search-term highlight — clear, high-contrast. */
mark.doclib-search-hl {
background: var(--accent, #e8a830);
color: var(--bg, #1a1a1a);
border-radius: 2px;
padding: 0 2px;
font-weight: 600;
}
/* Research bulk bar — right-align the action buttons (keep All + count on
the left). Cancel now lives on the Select toggle, so Archive anchors. */
#doclib-research-bulk #doclib-research-bulk-archive {
margin-left: auto;
}
/* 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. */
#gallery-editor-drafts-bulk {
justify-content: flex-start;
}
.doc-divider.doc-divider-handle-idle .doc-divider-collapse {
opacity: 0;
pointer-events: none;
}
.doc-divider:hover .doc-divider-collapse { opacity: 0.92; }
/* Smooth entrance — slide in from the left + fade up so it doesn't snap into
place when fullscreen activates. The chevron's vertical centering uses
translateY(-50%), so the animation has to keep that part. */
@keyframes doc-fs-chevron-in {
from { opacity: 0; transform: translateY(-50%) translateX(-18px); }
to { opacity: 0.85; transform: translateY(-50%) translateX(0); }
}
.doc-editor-pane.doc-pane-leaving {
pointer-events: none;
}
@keyframes doc-pane-leave {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(24px); opacity: 0; }
}
.doc-tab-scroll.is-at-left {
-webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 18px), transparent 100%);
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 18px), transparent 100%);
}
.doc-tab-scroll.is-at-right {
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 100%);
mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 100%);
}
.doc-tab-scroll.is-at-left.is-at-right {
-webkit-mask-image: none;
mask-image: none;
}
.doc-tab-scroll::-webkit-scrollbar { display: none; }
#doc-tab-right,
#doc-tab-left {
position: relative;
top: 3px;
}
/* Ghost tab shown in the empty state (new, not-yet-saved document). Muted +
italic so it reads as a placeholder, and non-interactive so clicking it
can't hit the tab handlers (it has no data-doc-id). */
.doc-tab.doc-tab-ghost {
/* New, not-yet-saved doc tab. It already carries .active, so it shows the
accent underline like any active tab — that's all we want. The old dashed
border + italic/dim "pending" styling looked weird, so they're gone. */
pointer-events: none;
}
.doc-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--red);
}
@keyframes doc-stream-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.doc-updated-flash {
animation: doc-update-flash 0.6s ease-out;
}
@keyframes doc-update-flash {
0% { box-shadow: inset 0 0 0 2px var(--accent); }
100% { box-shadow: inset 0 0 0 2px transparent; }
}
.doc-langpicker-trigger.doc-langpicker-email-compact {
width: 28px;
min-width: 28px;
padding: 0 0 0 6px;
justify-content: flex-start;
gap: 0;
}
/* The "T" icon stays centered in the fixed-size button so it grows
symmetrically (from the center) instead of jumping around as the size
changes. The S/M/L letter is pinned to the bottom-right corner, absolutely
positioned so it never shifts the icon either. */
#doc-fontsize-btn { transform: translateY(-1px); }
#doc-fontsize-btn svg { flex: 0 0 auto; }
.doc-overflow-wrapper { order: -1; }
.doc-overflow-toggle { opacity: 0.5; }
.doc-md-toolbar.md-preview-active,
.doc-md-toolbar.md-write-active {
overflow: visible;
-webkit-mask-image: none;
mask-image: none;
z-index: 3;
}
.doc-md-toolbar #doc-render-view-toggle .md-view-opt {
height: 28px;
}
/* Some toolbar buttons baked in svg{opacity:0.7} via inline styles, which
compounded with the button's own opacity to look almost invisible.
Force the inline svg opacity back to 1 inside the toolbar — the button
wrapper's 0.85 is the only fade we need. */
.doc-md-toolbar button svg[style*="opacity"] { opacity: 1 !important; }
/* No black tap-flash / focus background on the doc toolbar + tab controls —
they're icon buttons, the opacity change is the only feedback we want. */
.doc-md-toolbar button,
.doc-tab-new,
.doc-tab-arrow,
.doc-tab-play {
-webkit-tap-highlight-color: transparent;
}
/* Pull the icon down toward the "sign" label so they read as one unit. */
#doc-pdf-add-sign-btn svg { transform: translateY(2px); }
.doc-outline-count {
opacity: 0.48;
font-variant-numeric: tabular-nums;
}
.doc-outline-target {
animation: doc-outline-target-flash 1.05s ease-out;
}
@keyframes doc-outline-target-flash {
0%, 28% { background-color: color-mix(in srgb, var(--accent, var(--red)) 24%, transparent); }
100% { background-color: transparent; }
}
.doc-rich-slash-count {
opacity: 0.48;
font-variant-numeric: tabular-nums;
}
.doc-rich-slash-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Find marks live in the syntax-highlight overlay, which sits at
z-index:0 under a transparent textarea — so they're always visible
through the text layer. The previous color-mix variant could
compute to near-invisible on themes with dark/desaturated accents;
forced solid colors + !important so it ALWAYS pops. */
mark.doc-find-mark {
background: var(--accent) !important;
color: var(--bg) !important;
border-radius: 2px;
padding: 0 1px;
box-shadow: 0 0 0 1px var(--accent);
}
.doc-editor-highlight::-webkit-scrollbar { display: none; }
/* Arrow pointing toward the editor */
.doc-suggestion-card::before {
content: '';
position: absolute;
top: 16px;
left: -10px;
width: 18px;
height: 18px;
background: var(--panel);
border-left: 2px solid var(--accent);
border-bottom: 2px solid var(--accent);
transform: rotate(45deg);
z-index: 1;
}
.doc-suggestion-close::before {
content: '';
width: 10px;
height: 10px;
display: block;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2L8 8M8 2L2 8' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2L8 8M8 2L2 8' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
.doc-suggestion-accept:hover {
filter: brightness(1.15);
}
.doc-editor-wrap.animating::after {
content: '';
position: absolute;
top: 6px; right: 8px;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--red);
animation: doc-pulse 0.8s ease-in-out infinite;
z-index: 3;
pointer-events: none;
}
@keyframes doc-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
.doc-diff-content { }
@media (hover: none) {
.doc-preview-hover-edit {
opacity: 1;
pointer-events: auto;
transform: none;
}
}
#doclib-research-search { position: relative; top: -1.5px; }
/* Restore sits on the right of the Delete in archive previews. */
.doclib-chat-restore-btn { margin-left: auto; }
/* Delete + Archive pin to the left of the actions row; the
`margin-right: auto` on the last left-side button (Archive) pushes
Copy + Open to the right. Delete sits furthest left. */
.doclib-chat-archive-btn { margin-right: auto; }
@keyframes doclib-expand {
from { opacity: 0.5; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Hide the section headers while a card is expanded (full-screen reader). */
.doclib-grid:has(.doclib-card-expanded) > .skills-section-header { display: none; }
.skill-card:not(.doclib-card-expanded) .skill-card-tags { display: none; }
.doclib-chip-scroll-frame > .chip-scroll-strip:empty {
display: none;
}
.skill-card.doclib-card-expanded .skill-kebab-btn { display: none; }
.skill-card.doclib-card-expanded .skill-chevron-up { display: inline-flex; }
/* Size the expanded skill card to its content (footer sits right under the
preview) but cap at the modal height so a long SKILL.md scrolls instead
of overflowing. The inherited `.doclib-card-expanded { flex: 1 }` forced
the card to fill the whole modal, which left a big empty void between a
short skill's text and the footer. */
.skill-card.doclib-card-expanded {
flex: 0 1 auto !important;
max-height: 100% !important;
}
.doclib-inline-load-more::before {
display: none;
}
#doclib-selected-count {
color: color-mix(in srgb, var(--fg) 50%, transparent);
font-size: 10px;
}
.gallery-modal-content:has(#gallery-editor-container[style*="flex"]) .gallery-editor-landing {
width: 100%;
min-height: 0;
box-sizing: border-box;
}
#gallery-bulk-count,
#gallery-editor-drafts-bulk-count {
top: -1px !important;
}
#gallery-select-btn { position: relative; top: 4px !important; font-size: 12px; padding: 10px 11px 12px; min-width: 76px; justify-content: center; }
#gallery-albums-search { top: -4px; }
#gallery-albums-select-btn { top: -4px !important; }
#gallery-bulk-delete,
#gallery-albums-bulk-delete,
#gallery-editor-drafts-bulk-delete {
color: var(--color-error, #f44) !important;
}
.gallery-detail-image video {
max-width: 100%;
max-height: 70vh;
display: block;
}
.gallery-card:hover .gallery-fav-btn { opacity: 1; }
.gallery-card:hover .gallery-dl-btn { opacity: 1; }
@media (hover: none) {
.gallery-dl-btn { opacity: 0.55; }
}
/* Space the user's tag chips off the "add tag" input below them. */
#gallery-user-tag-chips { margin-bottom: 4px; }
.gallery-user-chip:hover .gallery-tag-x { opacity: 0.85; }
@keyframes gallery-skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.gallery-card:hover .gallery-card-overlay,
.gallery-card.gallery-card-focus .gallery-card-overlay {
opacity: 1;
}
/* Strong accent ring on the whole photo when selected. Paint it as a
foreground pseudo-element so the thumbnail cannot cover the feedback. */
.gallery-card:has(.gallery-select-dot.selected) {
border-color: var(--accent-primary, var(--red));
}
.gallery-card:has(.gallery-select-dot.selected)::after {
content: '';
position: absolute;
inset: 0;
z-index: 3;
border: 3px solid var(--accent-primary, var(--red));
border-radius: inherit;
box-sizing: border-box;
pointer-events: none;
}
.gallery-card:has(.gallery-select-dot.selected) .gallery-select-dot {
z-index: 4;
}
.gallery-detail-action.danger:hover {
border-color: var(--color-error, #e55);
}
.gallery-detail-image img,
.gallery-detail-image video,
.gallery-detail-img-frame img,
.gallery-detail-img-frame video {
max-width: 100%;
max-height: 100%;
border-radius: 6px;
object-fit: contain;
display: block;
}
.gallery-detail-image:hover .gallery-detail-nav { opacity: 0.85; }
@media (min-width: 769px) {
.gallery-modal-content:has(#gallery-detail[style*="flex"]) {
height: 92vh;
}
.gallery-modal-content:has(#gallery-detail[style*="flex"]) > .modal-body,
.gallery-images-container:has(> #gallery-detail[style*="flex"]),
#gallery-images-container:has(> #gallery-detail[style*="flex"]) > #gallery-detail {
height: 100%;
min-height: 0;
}
}
.gallery-detail-image:hover .gallery-detail-rotate { opacity: 0.85; }
.gallery-detail-nav-disabled {
opacity: 0 !important;
pointer-events: none;
}
@media (hover: none) {
.gallery-detail-nav { opacity: 0.7; }
}
.gallery-detail-inline-link:hover { text-decoration: underline; }
.gallery-detail-section {
margin-bottom: 12px;
}
.gallery-tag-input-wrap .gallery-tag-input { padding-right: 24px; }
.gallery-name-wrap:focus-within .gallery-name-enter { display: inline-flex; }
.gallery-name-wrap:focus-within .gallery-detail-name-input { padding-right: 28px; }
.gallery-album-cover img {
width: 100%; height: 100%;
object-fit: cover;
display: block;
}
.gallery-editor-landing p {
margin: 0;
opacity: 0.6;
font-size: 13px;
max-width: 320px;
}
.gallery-editor-landing-actions .gallery-select-btn:hover { opacity: 1; }
/* Saved-project selection uses the same bulk-bar rhythm as Photos. */
#gallery-editor-drafts-bulk {
margin-top: 0;
}
.gallery-editor-draft-card.selected {
outline: 2px solid var(--accent, var(--red));
outline-offset: -2px;
}
.gallery-editor-draft-card.select-mode { cursor: pointer; }
.gallery-drafts-view-toggle button svg {
display: block;
position: static;
transform: none;
}
.gallery-editor-drafts-grid.list-view {
display: flex;
flex-direction: column;
gap: 4px;
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-card {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 24px;
align-items: center;
gap: 9px;
min-height: 48px;
padding: 4px 6px;
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete-x { display: none; }
.gallery-editor-draft-card:focus-visible {
outline: 2px solid var(--accent, var(--red));
outline-offset: 2px;
}
.gallery-editor-draft-card:hover .gallery-editor-draft-delete { opacity: 1; }
.ge-topbar.ge-topbar-menu-open { z-index: 10006; }
/* Toolbar (left) */
@keyframes ge-layer-action-flash {
from { box-shadow: inset 0 0 0 2px var(--red); background-color: color-mix(in srgb, var(--red) 24%, transparent); }
to { box-shadow: inset 0 0 0 2px transparent; }
}
.ge-layer-action-flash { animation: ge-layer-action-flash 650ms ease-out; }
@media (prefers-reduced-motion: reduce) {
.ge-layer-action-flash { animation-duration: 1ms; }
}
.ge-tool-btn .ge-tool-label {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ge-tool-btn:hover::after {
content: '';
position: absolute;
inset: 0 2px 0 -2px;
background: color-mix(in srgb, var(--fg) 8%, transparent);
border-radius: 6px;
z-index: -1;
pointer-events: none;
}
.ge-tool-btn.active::before {
content: '';
position: absolute;
/* Highlight pill shifted 2 px left of the button bounds so it sits
under the icon + label (which are also nudged left). Same total
width as before — just slid. */
inset: 0 2px 0 -2px;
background: color-mix(in srgb, var(--red) 18%, transparent);
border-radius: 6px;
z-index: 0;
pointer-events: none;
}
.ge-btn-ai:hover .ge-btn-ai-mark { opacity: 1; }
.ge-tool-btn.has-selection .ge-tool-clear { display: inline-flex; }
.ge-canvas-area.ge-rulers-visible {
padding: 18px 0 0 18px;
box-sizing: border-box;
}
.ge-main-canvas {
touch-action: none;
}
.ge-canvas-area.ge-pan-ready,
.ge-canvas-area.ge-pan-ready .ge-main-canvas {
cursor: grab !important;
}
.ge-canvas-area.ge-panning,
.ge-canvas-area.ge-panning .ge-main-canvas {
cursor: grabbing !important;
user-select: none;
}
.ge-ai-command-collapsed .ge-ai-command-toggle {
display: inline-flex;
}
.ge-ai-command-collapsed .ge-ai-command-head,
.ge-ai-command-collapsed .ge-ai-command-form,
.ge-ai-command-collapsed .ge-ai-command-suggestions,
.ge-ai-command-collapsed .ge-ai-command-status {
display: none;
}
@media (hover: none) {
.ge-ai-command-collapsed {
bottom: 8px;
opacity: 0.72;
}
}
.ge-ai-command-clear[hidden] {
display: none;
}
.ge-ai-command-busy .ge-ai-command-run::after {
content: '';
width: 12px;
height: 12px;
border: 2px solid color-mix(in srgb, #fff 42%, transparent);
border-top-color: #fff;
border-radius: 50%;
animation: ge-ai-command-spin 0.7s linear infinite;
}
@keyframes ge-ai-command-spin {
to { transform: rotate(360deg); }
}
.ge-ai-command-suggestions[hidden] {
display: none;
}
.ge-ai-command-status:empty {
display: none;
}
/* Each shortcut row: key chips + description. The key chips are usually
chained with "+" so we use a small horizontal gap between siblings,
plus a larger left margin on the trailing text via the kbd:last-of-type
sibling rule — keeps "Ctrl+Shift+D Deselect" readable instead of glued. */
.ge-shortcuts-col > div {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
padding: 4px 0;
opacity: 0.85;
line-height: 1.5;
}
/* Push the description text away from the last kbd by adding margin
to any text node that follows a kbd. Flex+wrap handles wrap on narrow. */
.ge-shortcuts-col > div kbd + kbd { margin-left: 0; }
.ge-shortcuts-col > div kbd:last-of-type { margin-right: 6px; }
.ge-shortcuts-card kbd,
#ge-shortcuts-popover kbd {
display: inline-flex;
align-items: center;
justify-content: center;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border: 1px solid color-mix(in srgb, var(--accent, #cc6a3a) 55%, transparent);
border-bottom-width: 2px;
border-radius: 4px;
background: color-mix(in srgb, var(--accent, #cc6a3a) 14%, transparent);
color: var(--accent, #cc6a3a);
min-width: 18px;
line-height: 1.4;
}
.ge-quick-mask-bar[hidden] { display: none; }
.ge-marquee-dimensions[hidden] { display: none; }
.ge-layer-geometry-section .ge-section-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.ge-layer-geometry-grid input::-webkit-inner-spin-button,
.ge-layer-geometry-grid input::-webkit-outer-spin-button { appearance: none; }
#ge-brush-controls select,
#ge-eyedropper-section select {
height: 26px;
min-width: 0;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--fg);
font: inherit;
}
.ge-gradient-extra-stop .ge-text-field { min-width: 0; }
.ge-shape-gradient-add-stop { align-self: flex-start; }
.ge-text-font-field { grid-column: span 1; }
#ge-text-rasterize { align-self: flex-end; }
.ge-shape-types .ge-text-toggle { flex: 1 1 25%; }
#ge-shape-rasterize { align-self: flex-end; }
.ge-direct-text-editor:focus { outline: 1px solid color-mix(in srgb, var(--red) 55%, transparent); }
.ge-transform-popup.ge-transform-popup-dragging .ge-transform-popup-head { cursor: grabbing; }
/* Force the title to take all middle space so the [_][×] cluster
stays pinned to the right edge. */
.ge-transform-popup-head .ge-adj-title { flex: 1 1 auto !important; }
.ge-transform-popup-minimised .ge-transform-popup-body,
.ge-transform-popup-minimised .ge-transform-popup-hint {
display: none;
}
.ge-transform-popup-input::-webkit-outer-spin-button,
.ge-transform-popup-input::-webkit-inner-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
display: none;
}
.ge-transform-position-field .ge-transform-popup-input { width: 68px; }
.ge-transform-input-locked {
opacity: 0.4;
cursor: not-allowed;
}
.ge-transform-input-locked + .ge-transform-spin { display: none; }
.ge-transform-preserve-field[hidden] { display: none; }
/* Hide the controls wrapper entirely when every tool section inside is
hidden — otherwise the padding + border draws an empty band above the
layers panel even when no tool needs sliders/buttons. */
.ge-controls:not(:has(> *:not([style*="display: none"]):not([style*="display:none"]))) {
display: none;
}
/* Strip the native <input type="color"> chrome (border + padding around
the swatch in each engine) so the visible color fills the circle. */
.ge-color-picker::-webkit-color-swatch-wrapper { padding: 0; }
.ge-color-picker::-webkit-color-swatch {
border: none;
border-radius: 50%;
}
.ge-color-picker::-moz-color-swatch {
border: none;
border-radius: 50%;
}
.ge-color-picker.cp-swatch-input::selection { background: transparent; }
.ge-size-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 9px; height: 9px;
border-radius: 50%;
background: var(--red);
border: none; cursor: pointer;
}
.ge-size-slider:hover::-webkit-slider-thumb,
.ge-size-slider.is-using::-webkit-slider-thumb,
.ge-size-slider:active::-webkit-slider-thumb {
width: 16px; height: 16px;
}
.ge-size-slider::-moz-range-thumb {
width: 9px; height: 9px;
border-radius: 50%;
background: var(--red);
border: none; cursor: pointer;
}
.ge-size-slider:hover::-moz-range-thumb,
.ge-size-slider.is-using::-moz-range-thumb,
.ge-size-slider:active::-moz-range-thumb {
width: 16px; height: 16px;
}
.ge-eraser-row label > span[id$="-label"]:hover {
background: color-mix(in srgb, var(--fg) 10%, transparent);
}
.ge-eraser-row > span[id$="-label"].ge-slider-value:hover {
background: color-mix(in srgb, var(--fg) 10%, transparent);
opacity: 1;
}
.ge-merge-menu[hidden] { display: none; }
/* Inpaint Brush size slider — slightly larger thumb than the rest of
the panel since it's the primary interactive control. */
#ge-inpaint-brush-slider::-webkit-slider-thumb { width: 12px; height: 12px; }
#ge-inpaint-brush-slider:hover::-webkit-slider-thumb,
#ge-inpaint-brush-slider.is-using::-webkit-slider-thumb,
#ge-inpaint-brush-slider:active::-webkit-slider-thumb { width: 18px; height: 18px; }
#ge-inpaint-brush-slider::-moz-range-thumb { width: 12px; height: 12px; }
#ge-inpaint-brush-slider:hover::-moz-range-thumb,
#ge-inpaint-brush-slider.is-using::-moz-range-thumb,
#ge-inpaint-brush-slider:active::-moz-range-thumb { width: 18px; height: 18px; }
.ge-adj-popup .ge-adj-title { flex: 1 1 auto; }
.ge-adj-popup .ge-adj-head:active { cursor: grabbing; }
.ge-fx-dock-label { overflow: hidden; text-overflow: ellipsis; }
.ge-history-head:active { cursor: grabbing; }
@keyframes ge-canvas-spin { to { transform: rotate(360deg); } }
.ge-inline-swatch::-webkit-color-swatch-wrapper { padding: 0; }
.ge-inline-swatch::-webkit-color-swatch { border: none; border-radius: 2px; }
.ge-inline-swatch::-moz-color-swatch { border: none; border-radius: 2px; }
.ge-adj-cb-row .ge-adj-cb-dot {
width: 10px; height: 10px; border-radius: 50%;
display: inline-block; flex-shrink: 0;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fg) 20%, transparent);
}
.ge-adj-cb-tone-picker {
margin: 0 0 8px;
}
.ge-adj-hist-details {
margin-bottom: 8px;
}
.ge-adj-hist-details > summary {
font-size: 11px;
color: var(--fg-muted);
cursor: pointer;
padding: 4px 0;
list-style: none;
user-select: none;
}
.ge-adj-hist-details > summary::-webkit-details-marker { display: none; }
.ge-adj-hist-details > summary::before {
content: '▸ ';
display: inline-block;
transition: transform 0.12s;
}
.ge-mask-sub-item.active .ge-layer-name { opacity: 1; font-style: normal; font-weight: 600; }
.ge-mask-sub-item.active .ge-mask-inline-thumb { opacity: 1; }
/* Opacity preview — translucent disk */
#ge-eraser-preview-opacity { opacity: 0.5; }
/* Flow preview — dotted ring to suggest discrete stamping */
#ge-eraser-preview-flow {
background: none;
border: 2px dotted var(--fg);
opacity: 0.7;
width: 12px;
height: 12px;
}
/* Softness preview — blurred edge */
#ge-eraser-preview-softness {
background: radial-gradient(circle, var(--fg) 0%, var(--fg) 30%, transparent 75%);
opacity: 0.7;
}
/* Wand feather preview — solid disk that visibly softens at its edge
as the user drags the Feather slider. JS sets --feather-blur. */
#ge-wand-feather-preview {
filter: blur(var(--feather-blur, 0px));
opacity: 0.8;
}
.ge-mask-vis-btn.visible:hover { opacity: 1; }
.ge-topbar .ge-btn,
.ge-topbar .ge-btn-sm { text-transform: uppercase; }
.ge-stacked-btn .ge-stacked-glyph {
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
height: 14px;
}
.ge-layer-selection-bar[hidden] { display: none; }
.ge-layer-selection-bar .ge-icon-btn:hover { opacity: 1; }
.ge-save-menu[hidden] { display: none; }
.ge-stacked-btn .ge-stacked-glyph .ge-save-state-icon[hidden] { display: none !important; }
.ge-stacked-btn .ge-stacked-glyph .ge-save-state-icon:not([hidden]) { display: block; }
.ge-resize-menu[hidden],
.ge-edge-menu[hidden],
.ge-view-menu[hidden],
.ge-image-menu[hidden],
.ge-selection-menu[hidden],
.ge-filter-menu[hidden] { display: none; }
.ge-selection-save-row .ge-icon-btn { width: 28px; height: 27px; }
.ge-export-matte .ge-color-picker { width: 28px; height: 28px; }
.ge-export-footer > div { display: flex; gap: 6px; }
.ge-export-estimate { opacity: 0.6; font-variant-numeric: tabular-nums; }
.ge-canvas-prompt-custom-label { margin-top: 14px; }
.ge-canvas-anchor::after {
content: '';
position: absolute;
inset: 6px;
border-radius: 1px;
background: color-mix(in srgb, var(--fg) 38%, transparent);
}
.ge-canvas-anchor.active::after { background: var(--red); }
.ge-canvas-lock-option input { accent-color: var(--red); }
.ge-canvas-preset-icon { margin-bottom: 1px; }
/* Center grip dots so the handle reads as draggable. */
.ge-panel-resize::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 24px;
background: color-mix(in srgb, var(--fg) 35%, transparent);
border-radius: 2px;
}
.ge-slider-bubble[hidden] { display: none; }
/* Tail pointing DOWN at the slider (bubble sits above). */
.ge-slider-bubble::after {
content: '';
position: absolute;
left: 50%;
bottom: -4px;
transform: translateX(-50%);
width: 8px;
height: 8px;
background: var(--red);
clip-path: polygon(0 0, 100% 0, 50% 100%);
}
.ge-layer-thumb canvas {
display: block;
border-radius: 4px;
}
.ge-layers-merge-menu[hidden] { display: none; }
.ge-layer-item:not(.active) .ge-layer-inline-thumb { opacity: 0.86; }
.ge-layer-opacity-row .ge-layer-lock-btn { flex: 0 0 24px; width: 24px; }
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-controls {
grid-column: 1 / -1;
grid-row: 3;
}
.ge-layer-item.grouped { padding-left: calc(20px + (var(--layer-group-depth, 1) - 1) * 14px); }
.ge-layer-vis.visible:hover { opacity: 1; }
.ge-layer-item.clipped .ge-layer-name {
display: flex;
align-items: center;
gap: 4px;
}
/* Shrink the slider thumb — the native default is ~16-20 px which looks
massive next to a 3 px track in the compact layer row. */
.ge-layer-opacity::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--red);
border: none;
cursor: pointer;
/* Match the track: shrink only after a 0.5s grace period. */
transition: width 0.12s ease 0.5s, height 0.12s ease 0.5s;
}
.ge-layer-opacity::-moz-range-thumb {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--red);
border: none;
cursor: pointer;
transition: width 0.12s ease 0.5s, height 0.12s ease 0.5s;
}
.ge-layer-opacity:hover::-webkit-slider-thumb,
.ge-layer-opacity:active::-webkit-slider-thumb,
.ge-layer-opacity.dragging::-webkit-slider-thumb,
.ge-layer-opacity:hover::-moz-range-thumb,
.ge-layer-opacity:active::-moz-range-thumb,
.ge-layer-opacity.dragging::-moz-range-thumb {
transition: width 0.12s ease 0s, height 0.12s ease 0s;
}
.ge-layer-controls .ge-mask-density {
width: 26px;
min-width: 24px;
height: 4px;
flex: 0 0 26px;
align-self: center;
}
.ge-mask-properties > summary {
box-sizing: border-box;
width: 22px;
height: 22px;
padding: 0;
min-height: 0;
display: inline-flex;
align-items: center;
justify-content: center;
list-style: none;
cursor: pointer;
color: var(--fg);
opacity: 0.4;
background: none;
border: none;
border-radius: 4px;
transition: opacity 0.1s;
}
.ge-mask-properties > summary::-webkit-details-marker { display: none; }
.ge-mask-properties > summary > svg { width: 12px; height: 12px; flex: none; }
.ge-mask-sub-item:has(.ge-mask-properties[open]) {
position: relative;
z-index: 50;
}
.ge-mask-property-row output { text-align: right; opacity: .7; }
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-controls,
.ge-layers-list > .ge-layer-group-row > .ge-layer-controls {
display: none;
}
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active > .ge-layer-controls,
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active-parent > .ge-layer-controls,
.ge-layers-list > .ge-layer-group-row.active > .ge-layer-controls {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
padding-top: 4px;
border-top: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
}
.ge-layer-tools[hidden] { display: none; }
.ge-layer-tools .ge-layer-btn { min-width: 26px; }
@media (max-width: 700px) {
/* Preserve the compact grip while giving long-press reorder a usable
touch target. */
.ge-layer-drag {
width: 28px;
min-height: 30px;
margin-left: -6px;
margin-right: -4px;
}
}
.ge-layer-align-menu .ge-layer-lock-option { grid-template-columns: minmax(0, 1fr); }
.ge-crop-apply input[type="number"]::-webkit-outer-spin-button,
.ge-crop-apply input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.ge-brush-cursor::after {
content: '';
position: absolute;
top: 50%; left: 50%;
width: 3px; height: 3px;
margin: -1.5px 0 0 -1.5px;
background: rgba(255,255,255,0.8);
border-radius: 50%;
}
/* Marks remain solid so they pop through the otherwise-invisible
overlay; outlined extra-bright for the currently-focused one. */
body.doc-find-active mark.doc-find-mark {
background: var(--accent) !important;
color: var(--bg) !important;
}
body.doc-find-active mark.doc-find-mark.current {
background: var(--accent) !important;
box-shadow: 0 0 0 2px var(--fg) !important;
outline: 1px solid var(--fg) !important;
}
.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;
}
/* Documents footer — X + Undo stay on the LEFT, Copy/Export group pushed
to the RIGHT. Email composer keeps its original flex-end layout. */
#doc-actions-footer {
justify-content: flex-start;
}
.doc-save-button[data-save-state="dirty"] .doc-save-state-dirty,
.doc-save-button[data-save-state="saving"] .doc-save-state-saving,
.doc-save-button[data-save-state="saved"] .doc-save-state-saved,
.doc-save-button[data-save-state="error"] .doc-save-state-error { display: block; }
.doc-save-button[data-save-state="saving"] .doc-save-state-saving {
animation: doc-save-spin 800ms linear infinite;
}
@keyframes doc-save-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.doc-save-button[data-save-state="saving"] .doc-save-state-saving { animation: none; }
}
.doc-stats-popover[hidden] { display: none; }
.doc-stats-row strong {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.doc-email-richbody:empty::before {
content: "Write your email\2026";
opacity: 0.4;
pointer-events: none;
}
@media (max-width: 768px) {
.doc-rich-empty-import {
top: 106px;
}
}
.doc-email-richbody.richtext-mode:empty::before {
content: "Start writing\2026";
}
.doc-email-richbody.richtext-mode ul,
.doc-email-richbody.richtext-mode ol {
padding-left: 1.75em;
}
.doc-email-richbody.richtext-mode ul:not(.rich-checklist) > li,
.doc-email-richbody.richtext-mode ol > li {
padding-inline-start: 0.2em;
}
.doc-email-richbody.richtext-mode pre code {
padding: 0;
border: 0;
background: transparent;
font: inherit;
}
.doc-email-richbody figure.richtext-image .richtext-image-caption {
margin-top: 6px;
max-width: 100%;
color: color-mix(in srgb, var(--fg) 68%, transparent);
font-size: 0.84em;
line-height: 1.4;
text-align: center;
overflow-wrap: anywhere;
}
.doc-email-richbody a { color: var(--accent, #4af); }
.doc-email-richbody blockquote {
margin: 6px 0; padding-left: 10px;
border-left: 3px solid var(--border); opacity: 0.8;
}
.doc-email-richbody p { margin: 0.5em 0; }
#doc-email-more-btn svg { opacity: 0.8; }
@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;
}
}
.doclib-section-title-icon {
width: 14px;
height: 14px;
margin-right: 6px;
color: var(--accent, var(--red));
vertical-align: -2px;
}
File diff suppressed because it is too large Load Diff
+36 -3
View File
@@ -246,8 +246,32 @@
real request is discarded and the font fetched a second time. -->
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/style.css?v=20260921chatgptusage2">
<link rel="modulepreload" href="/static/app.js?v=20260921chatgptusage2">
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit">
<!-- Panel stylesheets split out of style.css for maintainability. They are
eager and their order is load-bearing: the cascade is the concatenation of
these files in this order, so they must stay in this order here and in the
PRECACHE list in sw.js. None of them is lazy-loaded. -->
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit">
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit">
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
<link rel="modulepreload" href="/static/js/sessions.js">
@@ -1527,6 +1551,15 @@
<span>System</span>
</button>
</div>
<!-- Build provenance, pinned below the scrolling nav so it stays at
the bottom-left of the panel: the version this build registers
and the commit the running process actually loaded. Populated
from /api/version; stays hidden until that answers. -->
<div class="settings-sidebar-build admin-only" id="settings-sidebar-build" hidden>
<span class="settings-sidebar-build-version" id="settings-sidebar-build-version"></span>
<span class="settings-sidebar-build-commit" id="settings-sidebar-build-commit"></span>
</div>
</div>
<div class="settings-panels">
@@ -2634,7 +2667,7 @@
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
<script type="module" src="/static/app.js?v=20260921chatgptusage2"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
+1 -1
View File
@@ -137,7 +137,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
| Module | Responsibility |
|---|---|
| **`gallery.js`** / **`galleryEditor.js`** | Gallery/image library and canvas editor entry points. |
| **`emailInbox.js`** / **`emailLibrary.js`** | Email inbox reader and library modal. Sub-modules handle signatures, reply recipients, state, and signature folding. |
| **`emailInbox.js`** / **`emailLibrary/`** | Email inbox reader and library modal. `emailLibrary.js` is a re-export wrapper; `emailLibrary/index.js` owns the panel, list and cards, with `settingsPage.js`, `unsubscribe.js`, `reader.js`, `menus.js`, `bodyRender.js`, `attachments.js` and `aiReply.js` alongside it plus the `utils.js` / `signatureFold.js` / `replyRecipients.js` / `state.js` leaves. |
| **`calendar.js`** / **`calendar/utils.js`** / **`calendar/reminders.js`** | Calendar views, event forms, reminders. |
| **`tasks.js`** | Scheduled task/recurring LLM job UI. |
| **`notes.js`** | Notes and todo panel, reminders, pinboard. |
+5
View File
@@ -3166,6 +3166,7 @@ function initMcpForm() {
if (transport === 'stdio' && !command) { msg.textContent = 'Command is required for stdio'; msg.className = 'admin-error'; return; }
if (transport === 'sse' && !url) { msg.textContent = 'URL is required for SSE'; msg.className = 'admin-error'; return; }
try { JSON.parse(env); } catch { msg.textContent = 'Env must be valid JSON'; msg.className = 'admin-error'; return; }
try { JSON.parse(args); } catch { msg.textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; msg.className = 'admin-error'; return; }
const fd = new FormData();
fd.append('name', name); fd.append('transport', transport); fd.append('command', command); fd.append('args', args); fd.append('env', env); fd.append('url', url);
// If preset has oauthFile config, send credentials for file generation
@@ -3186,6 +3187,10 @@ function initMcpForm() {
try {
const res = await fetch('/api/mcp/servers', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json();
if (!res.ok) {
msg.textContent = data.detail || `Failed (${res.status})`; msg.className = 'admin-error';
return;
}
if (data.needs_oauth) {
msg.innerHTML = `Added ${esc(name)} — <a href="/api/mcp/oauth/authorize/${data.id}" target="_blank" style="color:var(--red);font-weight:600;">Authorize with Google</a> to connect`;
msg.className = 'admin-success';
+10
View File
@@ -7549,6 +7549,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
<button type="button" class="md-toolbar-rich-only" data-md="subscript" title="Subscript" style="display:none"><span class="rich-script-icon">x<sub>2</sub></span></button>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
<button type="button" class="md-dd-toggle md-toolbar-edit-only" data-dd="heading" title="Heading (Ctrl+Alt+1-6)"><b>H</b><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<button type="button" class="md-dd-toggle md-toolbar-rich-only" data-dd="font" title="Font family" aria-label="Font family" style="display:none"><span>Aa</span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<span id="md-toolbar-sep-after-heading" class="md-toolbar-sep md-toolbar-manual-sep md-toolbar-edit-only" aria-hidden="true"></span>
<button type="button" class="md-dd-toggle md-toolbar-edit-only" data-dd="list" title="Bulleted list"><svg class="rich-list-bullet-icon" width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="4" cy="6" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="4" cy="18" r="1.5"/><path d="M9 5h12v2H9zM9 11h12v2H9zM9 17h12v2H9z"/></svg><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
@@ -7745,6 +7746,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
_renderDocumentStats();
statsPopover.hidden = false;
statsPopover.style.zIndex = String(topPortalZ());
// The editor pane clips overflow, so a footer-anchored absolute
// popover can disappear underneath the document. Float it against
// the viewport and place it above the stats button.
@@ -10721,6 +10723,13 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
['h5', 'Heading 5', 'H5'],
['h6', 'Heading 6', 'H6'],
],
font: [
['fontname:Arial', 'Arial', 'Aa'],
['fontname:Georgia', 'Georgia', 'Gg'],
['fontname:Times New Roman', 'Times New Roman', 'Tt'],
['fontname:Verdana', 'Verdana', 'Vv'],
['fontname:Courier New', 'Courier New', 'Cc'],
],
code: [['code', 'Inline code', '`'], ['codeblock', 'Code block', '```']],
list: [
['ul', 'Bullet list', '•'],
@@ -11140,6 +11149,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
'#md-toolbar-sep-after-ai-reply',
'#doc-ai-writing-btn',
'[data-dd="heading"]',
'[data-dd="font"]',
'#md-toolbar-sep-after-heading',
'[data-dd="textsize"]',
],
+25 -11387
View File
File diff suppressed because it is too large Load Diff
+450
View File
@@ -0,0 +1,450 @@
// static/js/emailLibrary/aiReply.js
//
// The AI-reply entry points and the reader submenus that sit next to them:
// reply-mode choice, the per-message context note (drafted into localStorage so
// a half-typed instruction survives a reload), the translate submenu, and the
// remind-me submenu that writes a todo note.
//
// The context-draft key is versioned (`…:v2:`) and scoped by account, folder
// and uid, so a draft cannot leak from one message to another.
import spinnerModule from '../spinner.js';
import { topPortalZ } from '../toolWindowZOrder.js';
import { showToast } from '../ui.js?v=20260916largetoolscroll1';
import { state } from './state.js';
import { _esc, _extractName } from './utils.js';
import { _snapEmailModalToLeftSidebar, _translateEmail } from './index.js';
const API_BASE = window.location.origin;
export function _aiReplyIcon(data) {
return '<svg class="email-ai-reply-icon" width="14" height="14" viewBox="0 0 24 24" fill="var(--accent-primary, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>';
}
function _summaryIcon(data) {
const fill = data?.cached_summary ? 'var(--accent-primary, var(--red))' : 'currentColor';
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="${fill}"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>`;
}
async function _emailTranslateLanguage() {
try {
const res = await fetch(`${API_BASE}/api/email/config`);
const cfg = await res.json();
return cfg?.email_translate_language || 'English';
} catch (_) {
return 'English';
}
}
async function _runAiReplyFromButton(btn, em, data, mode, noteHint = '') {
_snapEmailModalToLeftSidebar(btn.closest('.modal'));
btn.disabled = true;
const orig = btn.innerHTML;
let wp = null;
try {
wp = spinnerModule.createWhirlpool(14);
wp.element.style.cssText = 'width:14px;height:14px;display:inline-block;vertical-align:middle;position:relative;top:-2px;';
btn.innerHTML = '';
btn.appendChild(wp.element);
} catch (_) {}
try {
if (state._onEmailClick) {
return await state._onEmailClick({ email: em, emailData: data, mode, noteHint });
}
return false;
} finally {
try { wp && wp.stop(); } catch (_) {}
btn.disabled = false;
btn.innerHTML = orig;
}
}
const _AI_REPLY_CONTEXT_DRAFT_PREFIX = 'odysseus:email-ai-reply-context:v2:';
let _aiReplyChoiceOutsideClose = null;
function _aiReplyContextDraftKey(em, data) {
const accountId = data?.account_id || em?.account_id || state._libAccountId || '';
const folder = data?.folder || em?.folder || state._libFolder || 'INBOX';
const uid = data?.uid || em?.uid || data?.message_id || em?.message_id || '';
if (!uid) return '';
return _AI_REPLY_CONTEXT_DRAFT_PREFIX
+ [accountId, folder, uid].map(value => encodeURIComponent(String(value || ''))).join(':');
}
function _loadAiReplyContextDraft(key) {
if (!key) return '';
try { return localStorage.getItem(key) || ''; } catch (_) { return ''; }
}
function _saveAiReplyContextDraft(key, value) {
if (!key) return;
try {
const text = String(value || '');
if (text.trim()) localStorage.setItem(key, text);
else localStorage.removeItem(key);
} catch (_) {}
}
function _clearAiReplyContextDraft(key) {
if (!key) return;
try { localStorage.removeItem(key); } catch (_) {}
}
function _closeAiReplyChoice() {
if (_aiReplyChoiceOutsideClose) {
document.removeEventListener('click', _aiReplyChoiceOutsideClose, true);
_aiReplyChoiceOutsideClose = null;
}
document.querySelectorAll('.email-ai-reply-choice').forEach(el => {
const input = el.querySelector('[data-note-input]');
_saveAiReplyContextDraft(el.dataset.contextDraftKey || '', input?.value || '');
el.remove();
});
}
function _showAiReplyChoice(btn, em, data, positionAnchor = btn) {
_closeAiReplyChoice();
const rect = positionAnchor.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'email-ai-reply-choice';
/* Clamp width to viewport minus 16px margin so the menu never spills off
the right edge on narrow mobile screens. */
const menuMaxW = Math.min(220, window.innerWidth - 16);
const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8));
/* Vertical placement: prefer below the button, but flip above if
there's not enough room (e.g. button near bottom of viewport).
Estimated menu height is ~150px (textarea + buttons + padding). */
const estHeight = 150;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
let top;
if (spaceBelow >= estHeight || spaceBelow >= spaceAbove) {
top = Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8));
} else {
top = Math.max(8, rect.top - estHeight - 6);
}
menu.style.cssText = [
'position:fixed',
`left:${left}px`,
`top:${top}px`,
`max-width:${menuMaxW}px`,
`max-height:${window.innerHeight - 16}px`,
'overflow:auto',
'box-sizing:border-box',
`z-index:${topPortalZ()}`,
'display:flex',
'gap:6px',
'padding:6px',
'background:var(--bg,#111)',
'border:1px solid var(--border,#333)',
'border-radius:7px',
'box-shadow:0 8px 24px rgba(0,0,0,.28)',
].join(';');
menu.innerHTML = `
<div class="email-ai-reply-row" style="display:flex;flex-direction:column;gap:6px;min-width:180px;">
<textarea data-note-input rows="2" placeholder="Context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
<div style="display:flex;align-items:center;gap:4px;">
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Draft reply" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
Submit
</button>
</div>
</div>
`;
const noteInput = menu.querySelector('[data-note-input]');
const contextDraftKey = _aiReplyContextDraftKey(em, data);
menu.dataset.contextDraftKey = contextDraftKey;
noteInput.value = _loadAiReplyContextDraft(contextDraftKey);
noteInput.addEventListener('input', () => {
_saveAiReplyContextDraft(contextDraftKey, noteInput.value || '');
});
setTimeout(() => noteInput.focus(), 0);
menu.addEventListener('click', async (ev) => {
const choice = ev.target.closest('[data-mode]');
if (!choice) return;
ev.preventDefault();
ev.stopPropagation();
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
const noteHint = (noteInput.value || '').trim();
_saveAiReplyContextDraft(contextDraftKey, noteInput.value || '');
_closeAiReplyChoice();
const draftOpened = await _runAiReplyFromButton(btn, em, data, mode, noteHint);
if (draftOpened === true) _clearAiReplyContextDraft(contextDraftKey);
});
// Esc closes the popover; ignore plain clicks inside the menu so the
// textarea stays focused.
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
document.body.appendChild(menu);
// Outside-click closer: only fires when the click target is OUTSIDE
// the menu. The original handler closed on any click which made
// focusing the textarea immediately dismiss the popover.
const outsideClose = (ev) => {
if (menu.contains(ev.target)) return;
document.removeEventListener('click', outsideClose, true);
if (_aiReplyChoiceOutsideClose === outsideClose) _aiReplyChoiceOutsideClose = null;
_closeAiReplyChoice();
};
_aiReplyChoiceOutsideClose = outsideClose;
setTimeout(() => {
if (_aiReplyChoiceOutsideClose === outsideClose) {
document.addEventListener('click', outsideClose, true);
}
}, 0);
}
export function _handleAiReplyButton(ev, em, data, positionAnchor = ev.currentTarget) {
ev.stopPropagation();
const btn = ev.currentTarget;
// First click on a cached email surfaces the cached draft. Second
// click clears the cache and opens the Fast/Full + context menu so
// the user can ask for a fresh draft (with new steering).
if (data?.cached_ai_reply && !btn.dataset.shownOnce) {
btn.dataset.shownOnce = '1';
_runAiReplyFromButton(btn, em, data, 'ai-reply');
return;
}
if (data?.cached_ai_reply) {
data.cached_ai_reply = null;
btn.dataset.shownOnce = '';
}
_showAiReplyChoice(btn, em, data, positionAnchor);
}
export function _hasMultipleRecipients(data) {
// Count distinct addresses in To + Cc (minus the current user). Empty
// fallback when the user's address isn't yet known — no exclusion.
const myAddress = (window._myEmailAddress || '').toLowerCase();
const extractEmails = (str) => {
if (!str) return [];
return str.split(',')
.map(s => {
const m = s.match(/<([^>]+)>/);
return (m ? m[1] : s).trim().toLowerCase();
})
.filter(e => e && e !== myAddress);
};
const recipients = new Set([
...extractEmails(data.to),
...extractEmails(data.cc),
]);
// Sender counts as one other person too
if (data.from_address && data.from_address.toLowerCase() !== myAddress) {
recipients.add(data.from_address.toLowerCase());
}
return recipients.size > 1;
}
// _esc lives in ./emailLibrary/utils.js
export function _showEmailTranslateSubmenu(reader, parentDropdown) {
parentDropdown.innerHTML = '';
const header = document.createElement('div');
header.className = 'dropdown-item-compact';
header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
header.innerHTML = '<span>Translate to</span>';
parentDropdown.appendChild(header);
const customRow = document.createElement('div');
customRow.className = 'dropdown-item-compact email-translate-custom-row';
customRow.style.cssText = 'display:flex;gap:5px;align-items:center;padding:5px 7px;cursor:default;';
customRow.innerHTML = `
<input type="text" class="email-translate-custom-input" placeholder="Write language..." style="min-width:0;width:136px;height:26px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--fg);font:inherit;font-size:11px;padding:0 7px;">
<button type="button" class="email-translate-custom-go" style="height:26px;padding:0 8px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb, var(--fg) 5%, transparent);color:inherit;font:inherit;font-size:11px;cursor:pointer;">Go</button>
`;
parentDropdown.appendChild(customRow);
const input = customRow.querySelector('.email-translate-custom-input');
const go = customRow.querySelector('.email-translate-custom-go');
const runCustom = async () => {
const language = (input?.value || '').trim();
if (!language) {
input?.focus();
return;
}
parentDropdown.remove();
await _translateEmail(reader, language);
};
customRow.addEventListener('click', e => e.stopPropagation());
go?.addEventListener('click', async e => {
e.stopPropagation();
await runCustom();
});
input?.addEventListener('keydown', async e => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
await runCustom();
}
});
_emailTranslateLanguage().then(language => {
if (input && !input.value) input.value = language || 'English';
}).catch(() => {});
const languages = ['English', 'Swedish', 'Japanese', 'Spanish', 'French', 'German'];
for (const language of languages) {
const item = document.createElement('div');
item.className = 'dropdown-item-compact';
item.innerHTML = `<span>${language}</span>`;
item.addEventListener('click', async (e) => {
e.stopPropagation();
parentDropdown.remove();
await _translateEmail(reader, language);
});
parentDropdown.appendChild(item);
}
}
// ---- Reminder submenu (used by both email menus) ----
export function _showLibRemindSubmenu(em, parentDropdown) {
parentDropdown.innerHTML = '';
const header = document.createElement('div');
header.className = 'dropdown-item-compact';
header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
header.innerHTML = '<span>Remind me</span>';
parentDropdown.appendChild(header);
const now = new Date();
const laterToday = new Date(now);
const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0);
if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3*60*60*1000);
else laterToday.setTime(sixPm.getTime());
const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate()+1); tomorrow.setHours(8,0,0,0);
const daysUntilMon = (8 - now.getDay()) % 7 || 7;
const nextWeek = new Date(now); nextWeek.setDate(now.getDate()+daysUntilMon); nextWeek.setHours(8,0,0,0);
const presets = [
{ label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: laterToday },
{ label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: tomorrow },
{ label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday:'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: nextWeek },
];
for (const p of presets) {
const item = document.createElement('div');
item.className = 'dropdown-item-compact';
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
item.addEventListener('click', async (e) => {
e.stopPropagation();
parentDropdown.remove();
await _createEmailReplyReminder(em, p.date);
});
parentDropdown.appendChild(item);
}
const customItem = document.createElement('div');
customItem.className = 'dropdown-item-compact';
customItem.innerHTML = '<span>Pick date and time…</span>';
customItem.addEventListener('click', (e) => {
e.stopPropagation();
parentDropdown.remove();
const tmp = document.createElement('input');
tmp.type = 'datetime-local';
const def = new Date(tomorrow);
const pad = n => String(n).padStart(2,'0');
tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`;
tmp.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:13px;';
document.body.appendChild(tmp);
tmp.focus();
if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} }
tmp.addEventListener('change', async () => {
if (tmp.value) await _createEmailReplyReminder(em, new Date(tmp.value));
tmp.remove();
});
tmp.addEventListener('blur', () => setTimeout(() => tmp.remove(), 200));
});
parentDropdown.appendChild(customItem);
// "Note" — prompts for free-text and saves it as a note without a
// due_date, so no timer/reminder fires.
const noteItem = document.createElement('div');
noteItem.className = 'dropdown-item-compact';
noteItem.innerHTML = '<span>Note</span>';
noteItem.addEventListener('click', (e) => {
e.stopPropagation();
parentDropdown.remove();
_promptEmailNote(em);
});
parentDropdown.appendChild(noteItem);
}
function _promptEmailNote(em) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;padding:16px;';
const card = document.createElement('div');
card.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:280px;max-width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;box-shadow:0 12px 32px rgba(0,0,0,0.4);';
const subject = em.subject || '(no subject)';
card.innerHTML = `
<div style="font-size:11px;opacity:0.6;">Note about ${_esc(subject)}</div>
<textarea data-note placeholder="Write your note…" rows="4" style="resize:vertical;min-height:80px;font-family:inherit;font-size:12px;padding:7px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev,#1a1a1a);color:var(--fg);box-sizing:border-box;width:100%;"></textarea>
<div class="email-note-actions" style="display:flex;gap:6px;justify-content:flex-end;position:relative;top:4px;">
<button class="memory-toolbar-btn" data-act="cancel"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button>
<button class="memory-toolbar-btn active" data-act="save"><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" style="vertical-align:-1px;margin-right:4px;"><path d="M4 4h16v16H4z"/><path d="M8 8h8M8 12h8M8 16h5"/></svg>Save</button>
</div>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
const ta = card.querySelector('[data-note]');
setTimeout(() => ta.focus(), 0);
const close = () => overlay.remove();
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
card.querySelector('[data-act="cancel"]').addEventListener('click', close);
card.querySelector('[data-act="save"]').addEventListener('click', async () => {
const text = (ta.value || '').trim();
if (!text) { ta.focus(); return; }
close();
await _createEmailReplyReminder(em, null, text);
});
ta.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') close();
else if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') card.querySelector('[data-act="save"]').click();
});
}
async function _createEmailReplyReminder(em, dueDate, customText = '') {
const pad = n => String(n).padStart(2,'0');
const iso = dueDate
? `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}`
: null;
const fullFrom = em.from || em.sender || '';
// Extract just the first name from "First Last <email@x>" or fall back to email local part
let from = 'someone';
if (fullFrom) {
const fullName = _extractName(fullFrom);
if (fullName) {
// Strip quotes, take the first whitespace-separated word, capitalize
const first = fullName.replace(/^["']|["']$/g, '').trim().split(/[\s,]+/)[0] || '';
if (first) from = first.charAt(0).toUpperCase() + first.slice(1);
}
}
const subject = em.subject || '(no subject)';
const folder = state._libFolder || 'INBOX';
const deepLink = `${window.location.origin}/#email=${encodeURIComponent(folder)}:${em.uid}`;
const itemText = customText || `Reply to ${from}: ${subject}`;
const payload = {
title: `Reply: ${subject}`,
note_type: 'todo',
items: [
{ text: itemText, checked: false },
],
content: `Open email: ${deepLink}`,
label: 'email reminder',
source: 'email',
};
if (iso) payload.due_date = iso;
try {
const res = await fetch(`${API_BASE}/api/notes`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Failed');
const { showToast } = await import('../ui.js?v=20260916largetoolscroll1');
if (dueDate) {
const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
showToast(`Todo reminder set for ${fmt}`);
} else {
showToast('Reply note saved');
}
if ('Notification' in window && Notification.permission === 'default') {
try { Notification.requestPermission(); } catch {}
}
} catch (e) {
const { showError } = await import('../ui.js?v=20260916largetoolscroll1');
showError('Failed to create reminder');
}
}
+492
View File
@@ -0,0 +1,492 @@
// static/js/emailLibrary/attachments.js
//
// Attachment chips in a reader: building the markup, wiring open/download/
// "open in editor" per type, and the deferred load for messages whose
// attachment list was not in the list response.
//
// The deferred path exists because `/api/email/list` does not carry attachment
// metadata: a card can know it *has* attachments without knowing what they
// are, so the chips are rendered late and the card icon repaired afterwards.
import spinnerModule from '../spinner.js';
import * as Modals from '../modalManager.js';
import { state } from './state.js';
import { _esc } from './utils.js';
import { _acct, _openCalendarEventFromEmail, _prepareEmailWindowForDocument } from './index.js';
import { _openEmailAsTab, _openEmailWindow } from './reader.js';
const API_BASE = window.location.origin;
// Wire click handlers for attachment chips + "open in editor" sub-buttons
// inside a reader. Safe to call multiple times — uses dataset.wired flag to
// skip nodes that already have listeners.
function _wireAttachmentHandlers(reader, folder) {
const useFolder = folder || state._libFolder;
// Detect mobile here so the attachment-chip handler doesn't blow up with
// a ReferenceError when this fn is called from contexts that don't have
// _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow).
const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
reader.querySelectorAll('.email-attachments-download-all').forEach(btn => {
if (btn.dataset.wired === '1') return;
btn.dataset.wired = '1';
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
if (btn.dataset.downloading === '1') return;
const uid = btn.dataset.attUid;
const sourceFolder = btn.dataset.attFolder || useFolder;
const count = Number(btn.dataset.attCount || 0);
if (!uid) return;
const originalHtml = btn.innerHTML;
const originalTitle = btn.title;
btn.dataset.downloading = '1';
btn.classList.add('is-loading');
try {
const sp = window.spinnerModule || (await import('../spinner.js')).default;
const wp = sp.createWhirlpool(12);
wp.element.style.margin = '0';
btn.textContent = '';
btn.appendChild(wp.element);
const label = document.createElement('span');
label.textContent = 'All';
btn.appendChild(label);
} catch (_) {
btn.textContent = 'All...';
}
try {
const url = `${API_BASE}/api/email/attachments-download/${encodeURIComponent(uid)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) {
const msg = await res.text().catch(() => '');
console.error('attachments zip download failed', res.status, msg);
location.href = url;
return;
}
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `email-${uid}-attachments.zip`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {}
} catch (e) {
console.error('attachments zip download error', e);
try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError('Could not download attachments'); } catch (_) {}
} finally {
delete btn.dataset.downloading;
btn.classList.remove('is-loading');
btn.title = originalTitle;
btn.innerHTML = originalHtml;
}
});
});
reader.querySelectorAll('.email-attachment-calendar-open').forEach(openBtn => {
if (openBtn.dataset.wired === '1') return;
openBtn.dataset.wired = '1';
openBtn.addEventListener('click', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
if (openBtn.dataset.opening === '1') return;
const uid = openBtn.dataset.openUid;
const index = openBtn.dataset.openIndex;
const name = openBtn.dataset.openName || `calendar-${index}`;
const sourceFolder = openBtn.dataset.openFolder || useFolder;
if (!uid || index == null) return;
openBtn.dataset.opening = '1';
openBtn.classList.add('is-loading');
const originalHtml = openBtn.innerHTML;
try {
const wp = spinnerModule.createWhirlpool(12);
wp.element.style.margin = '0';
openBtn.textContent = '';
openBtn.appendChild(wp.element);
const folderQs = encodeURIComponent(sourceFolder);
const attachmentUrl = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`;
const attachmentRes = await fetch(attachmentUrl, { credentials: 'same-origin' });
if (!attachmentRes.ok) throw new Error(`HTTP ${attachmentRes.status}`);
const blob = await attachmentRes.blob();
const fd = new FormData();
fd.append('file', blob, name);
const importRes = await fetch(`${API_BASE}/api/calendar/import`, {
method: 'POST', body: fd, credentials: 'same-origin',
});
const result = await importRes.json().catch(() => ({}));
if (!importRes.ok || !result.ok) {
throw new Error(result.detail || result.error || `HTTP ${importRes.status}`);
}
const existingEventUid = Array.isArray(result.event_uids) ? String(result.event_uids[0] || '').trim() : '';
if (existingEventUid && Number(result.imported || 0) === 0 && Number(result.skipped || 0) > 0) {
_openCalendarEventFromEmail(existingEventUid);
}
try { uiModule.showToast && uiModule.showToast(`${result.imported || 0} event${result.imported === 1 ? '' : 's'} added to ${result.calendar || 'calendar'}`); } catch (_) {}
window.dispatchEvent(new CustomEvent('calendar-refresh'));
} catch (e) {
console.error('calendar attachment import failed', e);
try {
const { showError } = await import('../ui.js?v=20260916largetoolscroll1');
showError(`Couldn't add ${name} to calendar: ${e?.message || 'Import failed'}`);
} catch (_) {}
} finally {
delete openBtn.dataset.opening;
openBtn.classList.remove('is-loading');
openBtn.innerHTML = originalHtml;
}
});
});
reader.querySelectorAll('.email-attachment-open').forEach(openBtn => {
if (openBtn.dataset.wired === '1') return;
openBtn.dataset.wired = '1';
openBtn.addEventListener('click', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
if (openBtn.dataset.opening === '1') return;
const uid = openBtn.dataset.openUid;
const index = openBtn.dataset.openIndex;
const name = openBtn.dataset.openName || `attachment-${index}`;
const sourceFolder = openBtn.dataset.openFolder || useFolder;
if (!uid || index == null) return;
openBtn.dataset.opening = '1';
openBtn.classList.add('is-loading');
const origHtml = openBtn.innerHTML;
const wp = spinnerModule.createWhirlpool(12);
wp.element.style.margin = '0';
openBtn.textContent = '';
openBtn.appendChild(wp.element);
try {
const folderQs = encodeURIComponent(sourceFolder);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 55000);
let res;
try {
res = await fetch(
`${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`,
{ method: 'POST', credentials: 'same-origin', signal: controller.signal }
);
} finally {
clearTimeout(timeout);
}
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.doc_id) {
const msg = (json && json.error) || `HTTP ${res.status}`;
try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError(`Couldn't open ${name}: ${msg}`); } catch (_) { alert(`Couldn't open ${name}: ${msg}`); }
return;
}
try {
// Tab the email modal down only when the viewport cannot fit both
// Email and the document pane. Desktop keeps a side-by-side layout
// when there is room; mobile still gives the document the screen.
const ownerModal = openBtn.closest('.modal');
if (ownerModal && ownerModal.id && _prepareEmailWindowForDocument(ownerModal)) {
try {
const ok = Modals.minimize(ownerModal.id);
if (!ok) ownerModal.classList.add('hidden');
} catch (_) {
ownerModal.classList.add('hidden');
}
}
const docMod = await import('../document.js?v=20260916docctx2');
const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
if (typeof load === 'function') {
await load(json.doc_id);
} else {
location.href = `/?doc=${encodeURIComponent(json.doc_id)}`;
}
} catch (e) {
console.error('Open document failed:', e);
try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError('Document opened but panel could not mount'); } catch (_) {}
}
} catch (e) {
console.error('attachment-as-doc error', e);
const msg = e && e.name === 'AbortError'
? `Opening ${name} timed out. Try downloading it instead.`
: `Couldn't open ${name}`;
try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError(msg); } catch (_) {}
} finally {
delete openBtn.dataset.opening;
openBtn.classList.remove('is-loading');
openBtn.innerHTML = origHtml;
}
});
});
reader.querySelectorAll('.email-attachment-download').forEach(downloadBtn => {
if (downloadBtn.dataset.wired === '1') return;
downloadBtn.dataset.wired = '1';
downloadBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
const chip = downloadBtn.closest('.email-attachment-chip');
if (!chip) return;
// Reuse the established attachment download handler below. Marking the
// chip expanded bypasses its old first-click reveal behavior.
chip.classList.add('is-expanded');
chip.dataset.downloadTrigger = 'button';
chip.click();
});
downloadBtn.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
downloadBtn.click();
});
});
reader.querySelectorAll('.email-attachment-chip').forEach(chip => {
if (chip.dataset.wired === '1') return;
chip.dataset.wired = '1';
chip.addEventListener('click', async (ev) => {
if (ev.target.closest('.email-attachment-open, .email-attachment-calendar-open, .email-attachment-download')) return;
ev.stopPropagation();
ev.preventDefault();
const uid = chip.dataset.attUid;
const index = chip.dataset.attIndex;
const name = chip.dataset.attName || `attachment-${index}`;
const sourceFolder = chip.dataset.attFolder || useFolder;
if (!uid || index == null) return;
if (!chip.classList.contains('is-expanded')) {
reader.querySelectorAll('.email-attachment-chip.is-expanded').forEach(other => {
if (other !== chip) other.classList.remove('is-expanded');
});
chip.classList.add('is-expanded');
return;
}
const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
if (_isMobileUA) {
delete chip.dataset.downloadTrigger;
window.open(url, '_blank');
return;
}
// Swap the paperclip icon for a whirlpool spinner while the
// download is in flight, so large attachments give a clear cue
// they're loading. Restore on completion.
const buttonTriggered = chip.dataset.downloadTrigger === 'button';
const iconSvg = buttonTriggered
? chip.querySelector('.email-attachment-download > svg')
: chip.querySelector(':scope > svg');
const origIconHtml = iconSvg ? iconSvg.outerHTML : '';
let _wp = null;
let _spinnerHost = null;
try {
const sp = window.spinnerModule || (await import('../spinner.js')).default;
_wp = sp.createWhirlpool(12);
_spinnerHost = document.createElement('span');
_spinnerHost.className = 'email-attachment-spinner';
_spinnerHost.style.cssText = 'display:inline-flex;width:12px;height:12px;align-items:center;justify-content:center;flex-shrink:0;position:relative;top:-2px;';
_spinnerHost.appendChild(_wp.element);
if (iconSvg) iconSvg.replaceWith(_spinnerHost);
} catch (_) {}
const origOpacity = chip.style.opacity;
chip.style.opacity = '0.85';
try {
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) {
console.error('attachment download failed', res.status, await res.text().catch(() => ''));
location.href = url;
return;
}
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} catch (e) {
console.error('attachment download error', e);
location.href = url;
} finally {
delete chip.dataset.downloadTrigger;
chip.style.opacity = origOpacity;
if (_spinnerHost && _spinnerHost.parentNode && origIconHtml) {
const tmp = document.createElement('div');
tmp.innerHTML = origIconHtml;
const restored = tmp.firstChild;
if (restored) _spinnerHost.replaceWith(restored);
}
if (_wp) { try { _wp.destroy(); } catch (_) {} }
}
});
});
}
// Heuristic: skip "attachments" that are clearly inline images used by
// signatures / quoted-reply headers (small image files, Outlook-style
// image001.png placeholders, logo*.png, etc.). They aren't real user-
// shared attachments and adding them to the chips makes every email look
// like it has content the user needs to act on.
function _isLikelySignatureImage(a) {
if (!a || !a.filename) return false;
const name = String(a.filename).toLowerCase();
const isImage = /\.(png|jpe?g|gif|bmp|svg|webp)$/i.test(name);
if (!isImage) return false;
const size = Number(a.size) || 0;
// Outlook / Gmail inline image placeholders always look like this.
if (/^image\d{3,}\.(png|jpe?g|gif)$/i.test(name)) return true;
if (/^(signature|logo|sig|footer|banner)[-_\d]*\.(png|jpe?g|gif|svg)$/i.test(name)) return true;
// Most signature logos / inline thumbnails are < 30 KB. Real user-
// shared images (screenshots, photos) are typically 50 KB+.
if (size > 0 && size < 30 * 1024) return true;
return false;
}
// Build the attachments header+chips HTML for an email read response. Pulled
// out so both the initial-open and the swap-reader paths can render it.
export function _buildAttsHtmlFor(uid, data) {
if (!data) return '';
const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown|eml)$/i;
const _CALENDAR_RE = /\.(calendar|ics|ical)$/i;
const currentAttachments = Array.isArray(data.attachments) ? data.attachments : [];
const relatedAttachments = Array.isArray(data.related_attachments) ? data.related_attachments : [];
if (!currentAttachments.length && !relatedAttachments.length) return '';
const visible = currentAttachments.filter(a => !_isLikelySignatureImage(a));
const hidden = currentAttachments.filter(a => _isLikelySignatureImage(a));
const related = relatedAttachments.filter(a => !_isLikelySignatureImage(a));
if (!visible.length && !related.length && state._libViewInlineImages === false) return '';
const renderChip = (a, extraClass = '') => {
const calendarAttachment = _CALENDAR_RE.test(a.filename || '');
const openable = _OPENABLE_RE.test(a.filename || '');
const chipUid = a.source_uid || a.uid || uid;
const chipFolder = a.source_folder || data.folder || state._libFolder || 'INBOX';
const openBtn = calendarAttachment
? `<span class="email-attachment-calendar-open" role="button" tabindex="0" aria-label="Add to calendar" title="Add to calendar" data-open-uid="${_esc(chipUid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}" data-open-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="m8 15 2 2 5-5"/></svg></span>`
: openable
? `<span class="email-attachment-open" role="button" tabindex="0" aria-label="Open in document editor" title="Open in document editor" data-open-uid="${_esc(chipUid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}" data-open-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg></span>`
: '';
const downloadBtn = `<span class="email-attachment-download" role="button" tabindex="0" aria-label="Download attachment" title="Download attachment"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>`;
return `<button type="button" class="email-attachment-chip${extraClass}" data-att-uid="${_esc(chipUid)}" data-att-index="${a.index}" data-att-name="${_esc(a.filename)}" data-att-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>${_esc(a.filename)}</span><span class="att-size">${Math.round((a.size||0)/1024)} KB</span>${openBtn}${downloadBtn}</button>`;
};
const chips = visible.map(a => renderChip(a)).join('');
const hiddenChips = hidden.map(a => renderChip(a, ' email-attachment-chip-muted')).join('');
const relatedChips = related.map(a => renderChip(a, ' email-attachment-chip-related')).join('');
const visibleSection = visible.length
? '<div class="email-reader-atts">' + chips + '</div>'
: '';
const relatedSection = related.length
? '<div class="email-reader-atts-hidden-note">From earlier in this thread</div><div class="email-reader-atts email-reader-atts-related">' + relatedChips + '</div>'
: '';
const hiddenSection = hidden.length && state._libViewInlineImages !== false
? '<div class="email-reader-atts-hidden-note">Filtered inline images / signature files</div><div class="email-reader-atts email-reader-atts-hidden">' + hiddenChips + '</div>'
: '';
const label = visible.length
? `Attachments (${visible.length + related.length})`
: related.length
? `Thread attachments (${related.length})`
: state._libViewInlineImages === false
? 'Attachments'
: `Hidden inline attachments (${hidden.length})`;
const startCollapsed = !visible.length && !related.length;
const downloadAllBtn = visible.length > 4
? `<button type="button" class="email-attachments-download-all" title="Download all attachments" data-att-uid="${_esc(uid)}" data-att-folder="${_esc(data.folder || state._libFolder || 'INBOX')}" data-att-count="${visible.length}"><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"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span>All</span></button>`
: '';
return (
`<div class="email-reader-atts-wrap${startCollapsed ? ' collapsed' : ''}">`
+ '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
+ `<span>${label}</span>`
+ downloadAllBtn
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</div>'
+ visibleSection
+ relatedSection
+ hiddenSection
+ '</div>'
);
}
async function _ensureEmailAttachmentData(uid, folder, data, knownHasAttachments = false) {
if (!data) return data;
const current = Array.isArray(data.attachments) ? data.attachments : [];
const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
const shouldFetch = data.attachments_deferred || (knownHasAttachments && !current.length && !related.length);
if (!shouldFetch) return data;
try {
const metaRes = await fetch(`${API_BASE}/api/email/attachments/${encodeURIComponent(uid)}?folder=${encodeURIComponent(folder || 'INBOX')}${_acct()}`);
const meta = await metaRes.json().catch(() => ({}));
if (metaRes.ok && Array.isArray(meta.attachments)) {
return {
...data,
attachments: meta.attachments,
related_attachments: related,
attachments_deferred: false,
};
}
} catch (_) {}
// Do not full-fetch the message during ordinary open. That can download and
// parse large MIME bodies immediately after the reader renders, keeping IMAP
// busy and making the UI feel stuck. Full attachment lookup is still available
// from explicit attachment/download/forward actions.
return data;
}
export function _wireEmailAttachmentWrap(reader, folder) {
if (!reader) return;
const attsWrap = reader.querySelector('.email-reader-atts-wrap');
if (attsWrap && !attsWrap.dataset.wired) {
attsWrap.dataset.wired = '1';
const attsToggle = attsWrap.querySelector('.email-reader-atts-header');
if (attsToggle) {
attsToggle.addEventListener('click', (ev) => {
ev.stopPropagation();
attsWrap.classList.toggle('collapsed');
});
attsToggle.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
attsWrap.classList.toggle('collapsed');
}
});
}
}
try { _wireAttachmentHandlers(reader, folder); } catch {}
}
export function _loadDeferredAttachmentsIntoReader(reader, uid, folder, data, knownHasAttachments = false) {
if (!reader || !uid || !data) return;
const current = Array.isArray(data.attachments) ? data.attachments : [];
const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
if (!data.attachments_deferred && !(knownHasAttachments && !current.length && !related.length)) return;
const body = reader.querySelector('.email-reader-body');
if (!body) return;
const loading = document.createElement('div');
loading.className = 'email-reader-atts-wrap email-reader-atts-loading';
loading.style.cssText = 'min-height:34px;display:flex;flex-direction:row;align-items:center;gap:7px;padding:6px 14px;color:var(--fg);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;opacity:.7;';
const loadingSpinner = spinnerModule.createWhirlpool(12);
loadingSpinner.element.style.margin = '0';
loading.appendChild(loadingSpinner.element);
loading.appendChild(document.createTextNode('Loading attachments…'));
const existingWrap = reader.querySelector('.email-reader-atts-wrap');
if (existingWrap) existingWrap.replaceWith(loading);
else body.insertAdjacentElement('beforebegin', loading);
_ensureEmailAttachmentData(uid, folder, data, knownHasAttachments).then(fullData => {
if (!reader.isConnected || !fullData) return;
loading.remove();
try { loadingSpinner.destroy(); } catch (_) {}
const visibleCurrent = (fullData.attachments || []).filter(a => !_isLikelySignatureImage(a));
const matchingEmail = (state._libEmails || []).find(em => (
String(em.uid) === String(uid)
&& String(em.account_id || state._libAccountId || '') === String(reader.closest('[data-email-account]')?.dataset.emailAccount || state._libAccountId || '')
));
if (matchingEmail) matchingEmail.has_attachments = visibleCurrent.length > 0;
if (!visibleCurrent.length) {
reader.closest('.doclib-card')?.querySelector('.email-card-attachment')?.remove();
}
const attsHtml = _buildAttsHtmlFor(uid, fullData);
if (!attsHtml) return;
const oldWrap = reader.querySelector('.email-reader-atts-wrap');
if (oldWrap) {
const tmp = document.createElement('div');
tmp.innerHTML = attsHtml;
oldWrap.replaceWith(tmp.firstElementChild);
} else {
body.insertAdjacentHTML('beforebegin', attsHtml);
}
_wireEmailAttachmentWrap(reader, folder);
}).catch(() => {
loading.remove();
try { loadingSpinner.destroy(); } catch (_) {}
});
}
+871
View File
@@ -0,0 +1,871 @@
// static/js/emailLibrary/bodyRender.js
//
// Turning a fetched message into reader HTML: the plain and threaded body
// paths, inline MIME image handling, and the quote/thread folding that sits on
// top of the detectors in ./signatureFold.js.
//
// Everything here writes into a reader element via innerHTML, so every branch
// that touches server-supplied HTML goes through `_sanitizeHtml` from
// ./utils.js first. `tests/test_security_regressions.py` counts those call
// sites against the raw per-turn HTML reads, package-wide — so do not name that
// property in a comment, or the count stops matching.
import {
_QUOTE_ICON,
_SIG_ICON,
_extractQuoteMeta,
_extractTurnMetaFromBlockquote,
_foldSignature,
_foldSummary,
_harvestAttribution,
_isBloatedSig,
_looksLikeSignature,
} from './signatureFold.js';
import { state } from './state.js';
import {
_SIG_BLOAT_MIN_CHARS,
_TALON_ORIG_RE,
_TALON_WROTE,
_esc,
_escLinkify,
_formatBubbleDate,
_formatRecipients,
_initials,
_parseTurnMeta,
_sanitizeHtml,
_senderColor,
} from './utils.js';
import { _disableInlineImages } from './settingsPage.js';
const API_BASE = window.location.origin;
/**
* Wrap a probable signature block in a collapsed <details> so it stops
* eating the whole reader. We try, in priority order:
* 1. Mail-client signature wrappers — Gmail's `gmail_signature` div is
* explicit, no guessing required. Same for Apple Mail's data-smartmail.
* 2. The standard "-- " RFC 3676 sig delimiter.
* 3. A common closing phrase ("Best regards", "Cheers", etc.) on its own
* line — fuzzier, but catches sigs without the dash marker.
* 4. "Sent from my iPhone/Android" / "Get Outlook for ..." mobile-client
* boilerplate.
* Anything matched gets wrapped from the marker through end-of-body.
*/
/**
* Render the email body with sig/quote folds. If the backend has cached
* LLM-detected boundary offsets (data.boundaries), use those for an exact
* fold based on plain-text positions. Otherwise fall back to the regex
* detectors. The plain-body branch is always preferred when boundaries
* exist because the offsets are computed against plain text.
*/
// Global escape hatch — when the server's thread parser misfires (it
// occasionally splits a single reply into two bogus "turns" by treating a
// signature/disclaimer as its own message), the user can flip this off to
// fall back to plain rendering. Survives reloads.
const _BUBBLES_DISABLED_KEY = 'odysseus.email.bubblesDisabled';
// Threaded chat-bubble email view is DISABLED for now — too buggy to
// ship. Force plain-text rendering everywhere by always returning true.
// Re-enable by restoring the localStorage-backed body + the toggle
// menu item in the reader's More menu.
function _bubblesDisabled() {
return true;
}
function _setBubblesDisabled(v) {
try { localStorage.setItem(_BUBBLES_DISABLED_KEY, v ? '1' : '0'); } catch {}
}
export 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*<details\b/i.test(plainTurns.trim())) {
return _foldSignature(plainTurns, null);
}
return _foldSignature(_escLinkify(plain).replace(/\n/g, '<br>'), null);
}
// Prefer the normalized plain-text thread when the message contains
// explicit reply markers. HTML mail from Outlook/Gmail commonly wraps the
// same quoted message in several nested blockquotes; feeding that markup
// to the HTML walker creates phantom chains such as "Earlier reply >
// Earlier reply > sender". The plain representation has the actual quote
// levels and gives us one stable fold per real quoted message.
const hasPlainThreadMarkers = plain && (
/^\s*>/m.test(plain)
|| /^\s*-{5,}\s*(?:Previous message|Original message)\s*-{5,}\s*$/im.test(plain)
|| /^\s*On\s.+?\s(?:wrote|skrev|schrieb|écrit|escribió)\s*:\s*$/im.test(plain)
);
if (hasPlainThreadMarkers) {
const plainThread = _renderPlaintextThread(plain);
if (plainThread) return _foldSignature(plainThread, data && data.sender_signature || 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.
if (!_bubblesDisabled() && Array.isArray(data && data.thread_turns) && data.thread_turns.length) {
return _foldSignature(
_renderTurnsAsBubbles(data.thread_turns, data),
data && data.sender_signature || null,
);
}
const b = data && data.boundaries;
// Use cached boundaries when present AND we have plain-text body to slice
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.
let sig = (typeof b.sig_start === 'number' && b.sig_start >= 0) ? b.sig_start : -1;
let quote = (typeof b.quote_start === 'number' && b.quote_start >= 0) ? b.quote_start : -1;
// Clamp
if (sig >= plain.length) sig = -1;
if (quote >= plain.length) quote = -1;
let head = plain;
let sigSection = '';
let quoteSection = '';
if (sig >= 0 && quote >= 0) {
const earlier = Math.min(sig, quote);
head = plain.slice(0, earlier);
if (sig < quote) {
sigSection = plain.slice(sig, quote);
quoteSection = plain.slice(quote);
} else {
quoteSection = plain.slice(quote, sig);
sigSection = plain.slice(sig);
}
} else if (sig >= 0) {
head = plain.slice(0, sig);
sigSection = plain.slice(sig);
} else {
head = plain.slice(0, quote);
quoteSection = plain.slice(quote);
}
const fmt = (s) => _escLinkify(s).replace(/\n/g, '<br>');
let out = fmt(head);
if (quoteSection) {
out += '<details class="email-quote-fold">'
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(quoteSection))
+ fmt(quoteSection) + '</details>';
}
if (sigSection) {
const sigHtml = fmt(sigSection);
if (_isBloatedSig(sigHtml)) {
out += '<details class="email-sig-fold">' + _foldSummary('Signature', _SIG_ICON)
+ sigHtml + '</details>';
} else {
// Short closing — leave inline; folding would just add chrome.
out += sigHtml;
}
}
return out;
}
// Fallback: client-side parse (HTML or plaintext).
const hintSig = (data && data.sender_signature) || null;
const isHtml = !!data.body_html;
let rendered;
if (isHtml) {
rendered = _sanitizeHtml(data.body_html);
} else {
const plainTurns = _renderPlaintextThread(data.body || '');
if (plainTurns) return _foldSignature(plainTurns, hintSig);
rendered = _escLinkify(data.body || '').replace(/\n/g, '<br>');
}
const threaded = _renderThreadStructure(rendered);
if (threaded) return _prepareEmailInlineImages(_foldSignature(threaded, hintSig));
return _prepareEmailInlineImages(_foldSignature(_foldQuotedReplies(rendered), hintSig));
}
export function _safeRenderEmailBody(data) {
try {
return _prepareEmailInlineImages(_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, '<br>');
if (data?.body_html) return _prepareEmailInlineImages(_sanitizeHtml(data.body_html));
return '<span style="opacity:.65">No body</span>';
}
}
function _prepareEmailInlineImages(html) {
const raw = String(html || '');
if (!/<img[\s>]/i.test(raw)) return raw;
const doc = new DOMParser().parseFromString(`<div>${raw}</div>`, 'text/html');
const root = doc.body.firstElementChild;
if (!root) return raw;
root.querySelectorAll('img').forEach((img, idx) => {
const src = (img.getAttribute('src') || '').trim();
const alt = (img.getAttribute('alt') || img.getAttribute('title') || '').trim();
const isHttp = /^https?:\/\//i.test(src);
const isCid = /^cid:/i.test(src);
const cid = isCid ? src.replace(/^cid:/i, '').replace(/^<|>$/g, '').trim() : '';
const label = alt || (isCid ? 'Inline image' : 'Remote image');
const ph = doc.createElement('span');
ph.className = 'email-inline-image-placeholder';
ph.setAttribute('role', 'group');
ph.setAttribute('aria-label', label);
if (src) ph.dataset.emailImgSrc = src;
if (cid) ph.dataset.emailImgCid = cid;
ph.innerHTML = `
<span class="email-inline-image-skeleton">
<span class="email-inline-image-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
</span>
</span>
<span class="email-inline-image-info">
<span class="email-inline-image-title">${_esc(label)}</span>
<span class="email-inline-image-sub">${isHttp ? 'Remote image blocked' : isCid ? 'Inline image hidden' : 'Image unavailable'}</span>
</span>
<span class="email-inline-image-actions">
${isHttp ? `<a class="email-inline-image-btn email-inline-image-download-btn" href="${_esc(src)}" target="_blank" rel="noopener noreferrer" download title="Download" aria-label="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></a>` : ''}
${isCid ? `<button type="button" class="email-inline-image-btn email-inline-image-download-btn" data-email-img-download="${idx}" title="Download" aria-label="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>` : ''}
</span>
`;
img.replaceWith(ph);
});
return root.innerHTML;
}
export function _wireEmailInlineImages(reader) {
if (!reader) return;
const placeholders = Array.from(reader.querySelectorAll('.email-inline-image-placeholder'));
const visiblePlaceholders = placeholders.filter(ph => (
ph.isConnected &&
(ph.dataset.emailImgSrc || ph.dataset.emailImgCid) &&
getComputedStyle(ph).display !== 'none'
));
const autoLoadInlineImages = state._libViewInlineImages !== false;
const manualPlaceholders = autoLoadInlineImages
? visiblePlaceholders.filter(ph => !ph.dataset.emailImgCid)
: visiblePlaceholders;
const existingBulkBar = reader.querySelector('.email-inline-image-load-all');
if (!manualPlaceholders.length) existingBulkBar?.remove();
const loadPlaceholder = (ph) => {
if (!ph || !ph.isConnected) return;
const src = (ph.dataset.emailImgSrc || '').trim();
const cid = (ph.dataset.emailImgCid || '').trim();
const ctxRoot = reader.dataset?.emailUid
? reader
: ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]');
const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX';
const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || '';
const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || '';
const inlineUrl = () => {
if (!uid || !cid) return '';
const params = new URLSearchParams({ cid, folder });
if (account) params.set('account_id', account);
return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`;
};
const isRemoteImage = /^https?:\/\//i.test(src);
const isCidImage = !!cid && !isRemoteImage;
const loadSrc = isRemoteImage ? src : inlineUrl();
if (!loadSrc || ph.classList.contains('is-loading')) return;
ph.classList.remove('is-error');
ph.classList.add('is-loading');
const sub = ph.querySelector('.email-inline-image-sub');
if (sub) sub.textContent = 'Loading image...';
const actions = ph.querySelector('.email-inline-image-actions');
if (actions) actions.style.display = 'none';
const img = document.createElement('img');
img.className = 'email-inline-image-loaded';
img.alt = ph.querySelector('.email-inline-image-title')?.textContent || 'Loaded email image';
img.loading = 'eager';
img.referrerPolicy = 'no-referrer';
let settled = false;
let objectUrl = '';
let frame = null;
let directFallbackUsed = false;
const ensureFrame = () => {
if (frame) return frame;
frame = document.createElement('span');
frame.className = 'email-inline-image-frame is-loading';
frame.appendChild(img);
if (ph.isConnected) ph.replaceWith(frame);
return frame;
};
const showLoaded = () => {
if (settled) return;
settled = true;
if (frame) frame.classList.remove('is-loading');
img.classList.add('is-visible');
};
const showError = (err) => {
if (settled) return;
settled = true;
if (frame?.isConnected) frame.replaceWith(ph);
ph.classList.remove('is-loading');
ph.classList.add('is-error');
if (sub) sub.textContent = err?.message ? `Image failed: ${err.message}` : 'Image failed to load';
if (actions) actions.style.display = '';
if (objectUrl) {
try { URL.revokeObjectURL(objectUrl); } catch {}
}
};
const loadDirect = () => {
directFallbackUsed = true;
ensureFrame();
img.src = loadSrc;
if (img.complete && img.naturalWidth > 0) showLoaded();
};
img.addEventListener('load', showLoaded, { once: true });
img.addEventListener('error', () => {
if (!directFallbackUsed && objectUrl && isCidImage) {
try { URL.revokeObjectURL(objectUrl); } catch {}
objectUrl = '';
loadDirect();
return;
}
showError();
}, { once: true });
if (isCidImage || isRemoteImage) {
loadDirect();
setTimeout(() => {
if (!settled && frame) {
frame.classList.add('is-loading');
}
}, 12000);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
fetch(loadSrc, { credentials: 'same-origin', signal: controller.signal })
.then(async res => {
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
if (!blob || !blob.size) throw new Error('Empty image');
const type = blob.type && blob.type.startsWith('image/') ? blob.type : 'image/png';
objectUrl = URL.createObjectURL(blob.type === type ? blob : new Blob([blob], { type }));
ensureFrame();
img.src = objectUrl;
if (img.complete && img.naturalWidth > 0) showLoaded();
if (!settled && typeof img.decode === 'function') {
img.decode().then(showLoaded).catch(() => {
if (img.complete && img.naturalWidth === 0) showError();
});
}
})
.catch(err => {
clearTimeout(timeoutId);
showError(err?.name === 'AbortError' ? new Error('request timed out') : err);
});
setTimeout(() => {
if (!settled && !frame) {
ph.classList.remove('is-loading');
if (sub) sub.textContent = 'Still loading...';
if (actions) actions.style.display = '';
}
}, 12000);
};
if (manualPlaceholders.length && !reader.querySelector('.email-inline-image-load-all')) {
const first = manualPlaceholders[0];
const bar = document.createElement('div');
bar.className = 'email-inline-image-load-all';
const label = autoLoadInlineImages
? `${manualPlaceholders.length} external image${manualPlaceholders.length === 1 ? '' : 's'} blocked`
: `${manualPlaceholders.length} hidden inline image${manualPlaceholders.length === 1 ? '' : 's'}`;
bar.innerHTML = `
<span>${label}</span>
<button type="button" data-email-img-dismiss-all>Dismiss</button>
<button type="button" data-email-img-load-all>Load all</button>
`;
bar.querySelector('[data-email-img-load-all]')?.addEventListener('click', ev => {
ev.preventDefault();
ev.stopPropagation();
bar.remove();
manualPlaceholders.forEach(loadPlaceholder);
});
bar.querySelector('[data-email-img-dismiss-all]')?.addEventListener('click', ev => {
ev.preventDefault();
ev.stopPropagation();
bar.remove();
manualPlaceholders.forEach(ph => ph.remove());
_disableInlineImages().catch(() => {});
});
first.parentNode?.insertBefore(bar, first);
}
placeholders.forEach(ph => {
if (ph.dataset.wired !== '1') {
ph.dataset.wired = '1';
const src = (ph.dataset.emailImgSrc || '').trim();
const cid = (ph.dataset.emailImgCid || '').trim();
const ctxRoot = reader.dataset?.emailUid
? reader
: ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]');
const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX';
const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || '';
const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || '';
const inlineUrl = () => {
if (!uid || !cid) return '';
const params = new URLSearchParams({ cid, folder });
if (account) params.set('account_id', account);
return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`;
};
ph.querySelector('[data-email-img-download]')?.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const url = inlineUrl();
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.download = 'inline-image';
document.body.appendChild(a);
a.click();
a.remove();
});
}
const cid = (ph.dataset.emailImgCid || '').trim();
if (autoLoadInlineImages && cid && ph.isConnected && ph.dataset.emailImgAutoRequested !== '1') {
ph.dataset.emailImgAutoRequested = '1';
loadPlaceholder(ph);
}
});
}
// ── 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.
// Order is reversed so the oldest message sits at the top of the
// conversation and the newest (the message currently being read) sits
// at the bottom — matches the mental model people have from chat.
function _meEmailAddrs() {
const set = new Set();
for (const a of (state._libAccounts || [])) {
if (a && a.from_address) set.add(String(a.from_address).toLowerCase().trim());
if (a && a.imap_user) set.add(String(a.imap_user).toLowerCase().trim());
}
return set;
}
// _parseTurnMeta / _formatBubbleDate / _formatRecipients / _senderColor /
// _initials live in ./emailLibrary/utils.js
function _renderTurnsAsBubbles(turns, data) {
if (!Array.isArray(turns) || !turns.length) return '';
const mineSet = _meEmailAddrs();
const lvl0Email = String(data && data.from_address || '').toLowerCase().trim();
const lvl0Mine = !!lvl0Email && mineSet.has(lvl0Email);
const lvl0Author = (data && (data.from_name || data.from_address)) || '';
const lvl0Date = _formatBubbleDate(data && data.date);
// Newest reply on top, older history below. Turns come ordered shallow→deep
// (level 0 = current reply, deeper levels = older quoted material) so we
// render in source order without reversing.
const ordered = turns.slice();
// Gather per-turn sender identity + frequency for the no-self case below.
const turnIdentity = ordered.map((t) => {
if (t.level === 0) {
return { email: lvl0Email, author: lvl0Author };
}
const p = _parseTurnMeta(t.meta || '');
return { email: p.email, author: p.author };
});
const anyMine = turnIdentity.some(x => x.email && mineSet.has(x.email));
// When the user isn't a participant in this thread (forwarded chains,
// historical archives, etc.), assign the two most frequent senders to
// opposite sides so the conversation still reads side-to-side. Third+
// parties fall back to hash mod 2.
const sideForKey = (() => {
if (anyMine) return null;
const freq = new Map();
const firstSeen = new Map();
turnIdentity.forEach((x, i) => {
const key = (x.email || x.author || '').toLowerCase();
if (!key) return;
freq.set(key, (freq.get(key) || 0) + 1);
if (!firstSeen.has(key)) firstSeen.set(key, i);
});
const sorted = [...freq.entries()]
.sort((a, b) => (b[1] - a[1]) || (firstSeen.get(a[0]) - firstSeen.get(b[0])));
const leftKey = sorted[0] && sorted[0][0];
const rightKey = sorted[1] && sorted[1][0];
return (key) => {
if (!key) return 'theirs';
if (key === leftKey) return 'theirs';
if (key === rightKey) return 'mine';
// Stable hash for 3rd+ parties.
let h = 0;
for (let i = 0; i < key.length; i++) h = ((h << 5) - h + key.charCodeAt(i)) | 0;
return (h & 1) ? 'mine' : 'theirs';
};
})();
const rows = ordered.map((t, i) => {
let isMine, author, date;
if (t.level === 0) {
isMine = lvl0Mine;
author = lvl0Author || 'Me';
date = lvl0Date;
} else {
const p = _parseTurnMeta(t.meta || '');
isMine = !!p.email && mineSet.has(p.email);
author = p.author || (t.meta || 'Earlier reply');
date = p.date;
}
// No-self fallback: route by per-sender side mapping.
if (sideForKey) {
const id = turnIdentity[i];
const key = (id.email || id.author || '').toLowerCase();
isMine = sideForKey(key) === 'mine';
}
const side = isMine ? 'mine' : 'theirs';
const initials = _initials(author);
const color = _senderColor(author || (t.level === 0 ? lvl0Email : ''));
const head =
`<div class="email-bubble-head">`
+ `<span class="email-bubble-author" style="color:${color}">${_esc(author)}</span>`
+ (date ? `<span class="email-bubble-date">${_esc(date)}</span>` : '')
+ `</div>`;
const avatar = `<div class="email-bubble-avatar" aria-hidden="true" style="background:${color}">${_esc(initials)}</div>`;
return (
`<div class="email-bubble-row email-bubble-${side}" style="--bubble-accent:${color}">`
+ (isMine ? '' : avatar)
+ `<div class="email-bubble">`
+ head
+ `<div class="email-bubble-body">${_sanitizeHtml(t.body_html || '')}</div>`
+ `</div>`
+ (isMine ? avatar : '')
+ `</div>`
);
});
return `<div class="email-bubbles">${rows.join('')}</div>`;
}
/**
* Render server-cached thread turns (list of {level, body_html, meta})
* into the same nested-card structure the client-side parser produces.
*/
function _renderTurnsFromServer(turns) {
if (!Array.isArray(turns) || !turns.length) return '';
let out = '';
const stack = []; // [{ level, html }]
const wrap = (t) =>
`<details class="email-thread-turn email-quote-fold">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
+ `<div class="email-thread-turn-body">${t.html}</div>`
+ '</details>';
for (const t of turns) {
if (t.level === 0) {
while (stack.length) {
const top = stack.pop();
const w = wrap(top);
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
out += _sanitizeHtml(t.body_html || '');
} else {
while (stack.length && stack[stack.length - 1].level > t.level) {
const top = stack.pop();
const w = wrap(top);
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
if (!stack.length || stack[stack.length - 1].level < t.level) {
stack.push({ level: t.level, meta: t.meta, html: _sanitizeHtml(t.body_html || '') });
} else {
stack[stack.length - 1].html += _sanitizeHtml(t.body_html || '');
if (t.meta && !stack[stack.length - 1].meta) {
stack[stack.length - 1].meta = t.meta;
}
}
}
}
while (stack.length) {
const top = stack.pop();
const w = wrap(top);
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
// Mark the bottom-most fold for rounded corners.
const lastIdx = out.lastIndexOf('<details class="email-thread-turn email-quote-fold"');
if (lastIdx >= 0) {
out = out.slice(0, lastIdx)
+ out.slice(lastIdx).replace(
'email-thread-turn email-quote-fold"',
'email-thread-turn email-quote-fold last-fold"'
);
}
return out;
}
/**
* Parse an email body's reply chain into a stack of turn-cards.
* Each turn = { author, date, bodyHtml, nested[] } where the body is
* everything UP TO the next quote boundary, and `nested` is the sub-thread
* inside (recursively parsed). Returns null if the email has no quoted
* thread to parse (single message, no folds needed).
*/
// ── Talon-inspired multilingual quote-detection patterns ──
// Sources:
// github.com/mailgun/talon (HTML/text quote detection)
// github.com/crisp-oss/email-reply-parser (locale list)
//
// _TALON_* / _SIG_BLOAT_MIN_CHARS live in ./emailLibrary/utils.js
// _SIG_ICON / _QUOTE_ICON live in ./emailLibrary/signatureFold.js
function _renderThreadStructure(html) {
if (!html || typeof html !== 'string' || html.length > 200000) return null;
let doc;
try { doc = new DOMParser().parseFromString(`<div id="__t">${html}</div>`, 'text/html'); }
catch { return null; }
const root = doc.getElementById('__t');
if (!root) return null;
// Find top-level blockquotes (not nested inside another blockquote).
const tops = Array.from(root.querySelectorAll('blockquote')).filter(b =>
!b.parentElement.closest('blockquote')
);
if (!tops.length) return null;
// Build the current-message body: everything in root up to the first
// top-level blockquote, minus the "On <date>, <author> wrote:" attribution
// line that introduces it.
const head = doc.createElement('div');
let cursor = root.firstChild;
while (cursor && cursor !== tops[0]) {
const next = cursor.nextSibling;
head.appendChild(cursor);
cursor = next;
}
// Strip trailing "On <date>, <name> wrote:" / Outlook-style attribution
// from `head` since the same info will appear in the turn header.
let attribution = _harvestAttribution(head);
// Recursively parse each top-level blockquote into a turn (and its nested chain).
const turnsHtml = [];
for (let i = 0; i < tops.length; i++) {
const bq = tops[i];
// The blockquote may have an Outlook-style "From: / Sent: / Subject:"
// header inside as the first text. Extract that as the turn meta.
const meta = _extractTurnMetaFromBlockquote(bq) || attribution || _extractQuoteMeta(bq.innerHTML);
const innerHtml = bq.innerHTML;
// Heuristic: if a blockquote has no detectable attribution (no "From:",
// no "On <date>... wrote:") AND its content matches signature-style
// patterns (corporate disclaimer, "registered in", legal notices, just
// a name + title), treat it as a Signature fold instead of an Earlier
// Reply. This stops mail clients that wrap signatures in <blockquote>
// from making the signature appear as a phantom prior email.
if (!meta && _looksLikeSignature(innerHtml)) {
turnsHtml.push(
'<details class="email-sig-fold">'
+ _foldSummary('Signature', _SIG_ICON)
+ `<div class="email-sig-body">${innerHtml}</div>`
+ '</details>'
);
attribution = null;
continue;
}
// Recursively render the inside of this blockquote (which may contain
// its own nested blockquotes representing earlier replies).
const nested = _renderThreadStructure(innerHtml);
const bodyHtml = nested || innerHtml;
const isLast = i === tops.length - 1;
turnsHtml.push(
`<details class="email-thread-turn email-quote-fold${isLast ? ' last-fold' : ''}">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, meta || '')
+ `<div class="email-thread-turn-body">${bodyHtml}</div>`
+ '</details>'
);
// Only the first turn uses the harvested attribution; deeper turns
// get their own from inside the blockquote.
attribution = null;
}
return head.innerHTML + turnsHtml.join('');
}
// Looks like a signature / corporate disclaimer rather than a quoted email.
// Used to demote attribution-less blockquotes that some senders wrap their
// sig+disclaimer in (Outlook, EY, big firms) from "Earlier reply" to a
// proper Signature fold. Conservative — only fires when there's no quoted
// reply markers AND it matches strong corporate-noise phrases.
// _looksLikeSignature / _harvestAttribution / _extractTurnMetaFromBlockquote
// live in ./emailLibrary/signatureFold.js
/**
* Wrap any quoted reply chain in a collapsed <details> so deep email threads
* don't dominate the reader. Detects:
* - <blockquote> tags (Gmail / native quoted replies)
* - Outlook-style "From: ... Sent: ... To: ... Subject: ..." headers
* Each gets its own "Earlier thread" toggle.
*/
/**
* Parse a plaintext email body into stacked turn-cards by walking
* `> ` quote-prefix levels and Outlook-style "On X wrote:" / Original-Message
* boundaries. Returns rendered HTML, or null when there's no quoted content
* (caller falls back to flat rendering).
*
* Mirrors talon's `extract_from_plain` and email-reply-parser fragments:
* 1. Lines starting with one or more `>` chars are quoted (level = count of >).
* 2. Increasing the level opens a deeper turn (nested reply).
* 3. `-----Original Message-----` and `On <date>, <name> wrote:` start a
* new turn even without `>`.
* 4. The leading non-quoted segment is the current message.
*/
function _renderPlaintextThread(text) {
if (!text || typeof text !== 'string' || text.length > 200000) return null;
const lines = text.split(/\r?\n/);
const levels = lines.map(l => {
const m = l.match(/^((?:>\s?)+)/);
return m ? (m[1].match(/>/g) || []).length : 0;
});
const hasQuotes = levels.some(l => l > 0);
const attribLineRe = new RegExp(`(?:^|\\n)\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'im');
const hasAttrib = attribLineRe.test(text) || _TALON_ORIG_RE.test(text);
if (!hasQuotes && !hasAttrib) return null;
const turns = [];
let buf = [];
let curLevel = 0;
let pendingMeta = null;
const flush = () => {
if (!buf.length) return;
const t = buf.join('\n').trimEnd();
if (t || curLevel > 0) turns.push({ level: curLevel, text: t, meta: pendingMeta });
buf = [];
pendingMeta = null;
};
for (let i = 0; i < lines.length; i++) {
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 (isSeparatorLine || isAttribLine) {
flush();
pendingMeta = isSeparatorLine ? null : (_extractQuoteMeta(raw) || raw.trim());
curLevel = 1;
continue;
}
if (lvl !== curLevel) {
flush();
curLevel = lvl;
}
buf.push(stripped);
}
flush();
if (!turns.length || (turns.length === 1 && turns[0].level === 0)) return null;
const fmt = s => _escLinkify(s).replace(/\n/g, '<br>');
let out = '';
const stack = [];
const wrapTurn = (t) =>
`<details class="email-thread-turn email-quote-fold">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
+ `<div class="email-thread-turn-body">${t.html}</div>`
+ '</details>';
for (const t of turns) {
if (t.level === 0) {
while (stack.length) {
const top = stack.pop();
const wrapped = wrapTurn(top);
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
}
out += fmt(t.text);
} else {
while (stack.length && stack[stack.length - 1].level > t.level) {
const top = stack.pop();
const wrapped = wrapTurn(top);
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
}
if (!stack.length || stack[stack.length - 1].level < t.level) {
stack.push({ level: t.level, meta: t.meta, html: fmt(t.text) });
} else {
stack[stack.length - 1].html += '<br>' + fmt(t.text);
if (t.meta && !stack[stack.length - 1].meta) stack[stack.length - 1].meta = t.meta;
}
}
}
while (stack.length) {
const top = stack.pop();
const wrapped = wrapTurn(top);
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
}
const lastIdx = out.lastIndexOf('<details class="email-thread-turn email-quote-fold"');
if (lastIdx >= 0) {
out = out.slice(0, lastIdx)
+ out.slice(lastIdx).replace(
'email-thread-turn email-quote-fold"',
'email-thread-turn email-quote-fold last-fold"'
);
}
return out;
}
// _foldSummary / _extractQuoteMeta / _SIG_ICON / _QUOTE_ICON
// live in ./emailLibrary/signatureFold.js
function _foldQuotedReplies(html) {
if (!html || typeof html !== 'string') return html;
if (html.length > 200000) return html;
const before = html;
// Use DOMParser for proper nested-blockquote handling. Regex against HTML
// mishandles nesting and leaves orphan close tags that the browser
// re-balances, producing two visually inconsistent fold styles.
try {
const doc = new DOMParser().parseFromString(`<div id="__r">${html}</div>`, 'text/html');
const root = doc.getElementById('__r');
if (root) {
// Only fold TOP-LEVEL blockquotes (children of the root that are not
// already inside another blockquote). The inner blockquote chain stays
// intact inside the fold and renders with the existing
// .email-quote-fold blockquote styles, so everything matches.
const tops = Array.from(root.querySelectorAll('blockquote')).filter(b =>
!b.parentElement.closest('blockquote')
);
if (tops.length) {
for (const bq of tops) {
const det = doc.createElement('details');
det.className = 'email-quote-fold';
// Build the summary as raw HTML — easier than building DOM by hand.
const summary = _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(bq.innerHTML));
det.innerHTML = summary;
bq.parentNode.insertBefore(det, bq);
det.appendChild(bq); // move the original blockquote (and any nested ones) into the details
}
// Tag only the last fold so CSS can give it rounded bottom corners.
const allFolds = root.querySelectorAll('.email-quote-fold');
if (allFolds.length) allFolds[allFolds.length - 1].classList.add('last-fold');
return root.innerHTML;
}
}
} catch (e) {
// Fall through to the legacy regex path below if DOMParser fails
}
// If DOM-pass already wrapped something, we returned above. Otherwise no
// blockquotes were found — try the Outlook-header heuristic.
if (html !== before) return html;
// Outlook-style quoted-reply header — multilingual. Fold from the first
// "From: ... Sent: ... Subject: ..." block through end-of-body so all
// prior thread levels collapse together.
const FROM = '(?:From|Från|Von|De|De\\s|Da|От|Od|Van)';
const SENT = '(?:Sent|Skickat|Gesendet|Envoyé|Inviato|Enviado|Verzonden|Отправлено|Wysłane)';
const SUBJ = '(?:Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat)';
const outlookRe = new RegExp(
`(<br\\s*/?>|</p>|</div>|<p[^>]*>|<div[^>]*>|\\n)\\s*((?:<[^>]+>\\s*)*${FROM}\\s*:\\s*[^<\\n]+(?:<[^>]+>\\s*|\\s)*${SENT}\\s*:[\\s\\S]+?${SUBJ}\\s*:[\\s\\S]+)$`,
'i'
);
const m = html.match(outlookRe);
if (m) {
const idx = html.lastIndexOf(m[0]);
// Outlook fallback only ever produces ONE fold, so tag it as last.
html = html.slice(0, idx) + m[1]
+ '<details class="email-quote-fold last-fold">'
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2]))
+ m[2] + '</details>';
}
return html;
}
File diff suppressed because it is too large Load Diff
+856
View File
@@ -0,0 +1,856 @@
// static/js/emailLibrary/menus.js
//
// Every action menu the email library opens: the reader's More menu, a card's
// kebab menu, the bulk-selection Actions menu, and `_bulkAction`, which runs
// the chosen bulk mutation.
//
// Item order is not local — it comes from `orderActionMenuItems` in
// ../actionMenuOrder.js, so an action keeps the same position here as in the
// document, task, session and memory menus. `tests/test_action_menu_order.py`
// pins that.
import spinnerModule from '../spinner.js';
import { SELECT_MENU_ICON, actionMenuRank, orderActionMenuItems } from '../actionMenuOrder.js';
import { bindMenuDismiss, dismissOrRemove } from '../escMenuStack.js';
import { topPortalZ } from '../toolWindowZOrder.js';
import { showToast, styledConfirm } from '../ui.js?v=20260916largetoolscroll1';
import { state } from './state.js';
import {
_handleAiReplyButton,
_showEmailTranslateSubmenu,
_showLibRemindSubmenu,
} from './aiReply.js';
import {
_acct,
_animateEmailCardRemoval,
_clearDoneResponseTagsLocal,
_emailMutationQuery,
_exportSelectedAttachments,
_findSiblingEmailCard,
_fitEmailDropdown,
_libCacheWriteBack,
_loadEmailsFresh,
_renderGrid,
_requireSuccessfulEmailMutation,
_showEmailDeleteOverlay,
_syncEmailReadState,
_toggleCardPreview,
} from './index.js';
import { _openEmailAsTab } from './reader.js';
const API_BASE = window.location.origin;
export function _showReaderMoreMenu(em, card, reader, anchor, data) {
// Toggle: if a dropdown for THIS anchor is already open, close it.
const existing = document.querySelector('.email-card-dropdown');
if (existing && existing._anchor === anchor) {
dismissOrRemove(existing);
return;
}
// Otherwise close any other open dropdown (its own teardown clears its
// anchor's active state) before opening a fresh one.
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown';
dropdown._anchor = anchor;
anchor.classList.add('reader-more-active');
const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;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;right:${window.innerWidth - rect.right}px;`;
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _unreadIcon = '<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 _archIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
const _spamIcon = '<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="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
const _trashIcon = '<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="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
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 _translateIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent-primary, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>';
const closeAndRemove = async () => {
// Pick the next neighbour BEFORE we re-render so we know which email to
// jump to. Prefer the next card; fall back to the previous one if this
// was the last card.
const sibling = _findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1);
const nextUid = sibling ? sibling.dataset.uid : null;
await _animateEmailCardRemoval([em.uid]);
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
_renderGrid();
_libCacheWriteBack();
if (!nextUid) return;
// After _renderGrid, the card nodes are fresh — re-resolve and expand.
const grid = document.getElementById('email-lib-grid');
const nextCard = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(nextUid))}"]`);
const nextEm = state._libEmails.find(e => String(e.uid) === String(nextUid));
if (nextCard && nextEm) {
_toggleCardPreview(nextCard, nextEm);
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
};
const _bubblesIcon = '<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const _contactIcon = '<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="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>';
// Three groups separated by dividers:
// 1. Open / Mark Unread / Remind — the per-email view actions
// 2. Save sender / Not Done / Archive — non-destructive state changes
// 3. Move to Spam / Move to Trash / Delete — destructive
const overflowActions = Array.from(reader.querySelectorAll('.reader-action-overflowed')).map(button => ({
label: button.querySelector('.reader-btn-label')?.textContent?.trim() || button.title || 'Action',
icon: button.querySelector('svg')?.outerHTML || '',
button,
action: (positionAnchor) => {
if (button.dataset.act === 'ai-reply') {
_handleAiReplyButton({
currentTarget: button,
stopPropagation() {},
}, em, data, positionAnchor || button);
} else {
button.click();
}
},
}));
const actions = [
...overflowActions,
...(overflowActions.length ? [{ separator: true }] : []),
{
label: 'Open in new tab',
icon: _newTabIcon,
action: async () => {
const folder = state._libFolder || 'INBOX';
await _openEmailAsTab(em, folder);
},
},
{
label: 'Remind to reply',
icon: _bellIcon,
submenu: 'remind',
},
{
label: 'Translate',
icon: _translateIcon,
submenu: 'translate',
},
{ separator: true },
{
label: em.is_read ? 'Mark as Unread' : 'Mark as Read',
icon: _unreadIcon,
action: async () => {
const newRead = !em.is_read;
_syncEmailReadState(em.uid, newRead);
try {
if (newRead) {
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} else {
await fetch(`${API_BASE}/api/email/mark-unread/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
}
} catch (e) { console.error(e); }
_renderGrid();
},
},
{
// Favorite (pin to top). Same bookmark glyph we use for the
// sidebar-pin / favorites filter so the visual language stays
// consistent. Toggling updates em.is_flagged and re-sorts via
// _renderGrid (favorited rows are always pinned at the top).
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
action: async () => {
const next = !em.is_flagged;
em.is_flagged = next;
_renderGrid();
try {
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
} catch (e) {
// Roll back the optimistic flip if the server didn't take it.
em.is_flagged = !next;
_renderGrid();
console.error('Failed to toggle favorite:', e);
}
},
},
{
label: em.is_answered ? 'Mark as Not Done' : 'Mark as Done',
icon: _checkIcon,
action: async () => {
const newState = !em.is_answered;
em.is_answered = newState;
if (newState) {
_clearDoneResponseTagsLocal(em);
_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: 'Move to Archive',
icon: _archIcon,
action: async () => {
try {
await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} catch (e) { console.error(e); }
await closeAndRemove();
},
},
{
// Save the sender to CardDAV contacts. Pulls name + address off the
// list-item (em); falls back to splitting the local-part for a name.
label: 'Save sender to contacts',
icon: _contactIcon,
action: async () => {
const email = (em.from_address || em.from || '').trim();
if (!email) {
import('../ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('No sender address')).catch(() => {});
return;
}
const name = (em.from_name || '').trim() || email.split('@')[0];
try {
const r = await fetch(`${API_BASE}/api/contacts/add`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email }),
});
const d = await r.json();
import('../ui.js?v=20260916largetoolscroll1').then(m => {
if (!m.showToast) return;
if (d.success && d.message === 'Already exists') m.showToast('Already in contacts');
else if (d.success) m.showToast('Saved to contacts');
else m.showError && m.showError('Failed to save contact');
}).catch(() => {});
} catch (_) {
import('../ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Failed to save contact')).catch(() => {});
}
},
},
{ separator: true },
{
label: 'Move to Spam',
icon: _spamIcon,
action: async () => {
try {
await fetch(`${API_BASE}/api/email/move/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&dest=Junk`, { method: 'POST' });
} catch (e) { console.error(e); }
await closeAndRemove();
},
},
{
label: 'Move to Trash',
icon: _trashIcon,
action: async () => {
const busy = _showEmailDeleteOverlay(card);
await busy?.ready;
try {
const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
} catch (e) {
console.error(e);
busy?.remove?.();
showToast('Failed to delete email');
return;
}
busy?.remove?.();
await closeAndRemove();
},
},
{
label: 'Delete Permanently',
icon: _deleteForeverIcon,
danger: true,
action: async () => {
const subject = em.subject || '(no subject)';
const ok = await styledConfirm(
`Permanently delete "${subject}"? This cannot be undone.`,
{ confirmText: 'Delete', cancelText: 'Cancel', danger: true }
);
if (!ok) return;
const busy = _showEmailDeleteOverlay(card);
await busy?.ready;
try {
const response = await fetch(`${API_BASE}/api/email/delete-permanent/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
} catch (e) {
console.error(e);
busy?.remove?.();
showToast('Failed to delete email');
return;
}
busy?.remove?.();
await closeAndRemove();
},
},
];
for (const a of actions) {
if (a.separator) {
const sep = document.createElement('div');
sep.className = 'dropdown-divider';
dropdown.appendChild(sep);
continue;
}
const item = document.createElement('div');
item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
const arrow = a.submenu ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${arrow}`;
item.addEventListener('click', (e) => {
e.stopPropagation();
if (a.submenu === 'remind') {
_showLibRemindSubmenu(em, dropdown);
return;
}
if (a.submenu === 'translate') {
_showEmailTranslateSubmenu(reader, dropdown);
return;
}
// A hidden overflowed button has a zero-sized client rect. Invoke the
// AI chooser while the visible More item is still mounted, then close
// the action menu after the chooser has captured its position.
if (a.button?.dataset.act === 'ai-reply') {
a.action(item);
close();
return;
}
close();
a.action();
});
dropdown.appendChild(item);
}
// Mobile-only Cancel item — explicit close for touch users. CSS hides it
// on desktop where outside-click already dismisses cleanly.
const _cancelIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const cancelItem = document.createElement('div');
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
cancelItem.addEventListener('click', (e) => {
e.stopPropagation();
close();
});
dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
anchor.classList.remove('reader-more-active');
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
export function _showCardMenu(em, anchor) {
const openMenu = document.querySelector('.email-card-dropdown:not(.email-bulk-menu)');
if (openMenu && openMenu._emailMenuAnchor === anchor) {
dismissOrRemove(openMenu);
return;
}
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown';
dropdown._emailMenuAnchor = anchor;
const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;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;right:${window.innerWidth - rect.right}px;`;
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
const _archIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
const _delIcon = '<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="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
const _unreadIcon = '<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 _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 _cardBellIcon = '<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 isSentFolder = /sent/i.test(state._libFolder);
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 actions = [
{ label: 'Open', icon: _replyIcon, action: async () => {
// Just expand inline (same as tapping the row).
const card = anchor.closest('.doclib-card');
if (card && !card.classList.contains('doclib-card-expanded')) {
await _toggleCardPreview(card, em);
}
}},
{ label: 'Open in new tab', icon: _newTabIcon, action: async () => {
// Open this email as its own in-app modal that registers a dock
// chip — multiple emails can be opened simultaneously, each gets
// its own chip in the minimized dock.
const folder = state._libFolder || 'INBOX';
await _openEmailAsTab(em, folder);
}},
{ label: 'Remind to reply', icon: _cardBellIcon, submenu: 'remind' },
];
if (!isSentFolder) {
// Source of truth = the visible "active" class on the card's done
// check, so the menu label and the actual toggle behaviour can't
// disagree with what the user sees.
const _cardForLabel = anchor.closest('.doclib-card');
const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null;
const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered;
actions.push({
label: _currentlyDone ? 'Not Done' : 'Done',
icon: _checkIcon,
action: async () => {
const card = anchor.closest('.doclib-card');
const check = card ? card.querySelector('.email-card-done') : null;
const wasActive = check ? check.classList.contains('active') : !!em.is_answered;
const newState = !wasActive;
em.is_answered = newState;
if (newState) {
_clearDoneResponseTagsLocal(em);
_syncEmailReadState(em.uid, true); // mark-done implies mark-read
}
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); }
if (card) {
if (check) check.classList.toggle('active', newState);
if (newState) {
_syncEmailReadState(em.uid, true);
card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
}
}
},
});
actions.push({
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
action: async () => {
const next = !em.is_flagged;
em.is_flagged = next;
_renderGrid();
try {
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
} catch (e) {
em.is_flagged = !next;
_renderGrid();
console.error('Failed to toggle favorite:', e);
}
},
});
actions.push({
label: 'Archive',
icon: _archIcon,
action: async () => {
await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await _animateEmailCardRemoval([em.uid]);
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
_renderGrid();
_libCacheWriteBack();
},
});
} else {
actions.push({
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
action: async () => {
const next = !em.is_flagged;
em.is_flagged = next;
_renderGrid();
try {
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
} catch (e) {
em.is_flagged = !next;
_renderGrid();
console.error('Failed to toggle favorite:', e);
}
},
});
actions.push({
label: 'Archive',
icon: _archIcon,
action: async () => {
await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await _animateEmailCardRemoval([em.uid]);
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
_renderGrid();
_libCacheWriteBack();
},
});
}
// "Select" — switch to multi-select mode with THIS email pre-selected so
// the user can quickly fan-out to neighbours with the bulk bar.
// Match the chat-sidebar Select icon — a thick bullet character reads
// much heavier than a small SVG circle. Nudged up 2px so its visual
// center lines up with the SVG icons above (which sit a bit higher).
const _selectIcon = SELECT_MENU_ICON;
actions.push({
label: 'Select',
icon: _selectIcon,
action: () => {
state._selectMode = true;
state._selectedUids.add(em.uid);
_updateBulkBar();
_renderGrid();
},
});
actions.push(
{ label: 'Delete', icon: _delIcon, danger: true, action: async () => {
const subject = em.subject || '(no subject)';
const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true });
if (!ok) return;
const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(em.uid))}"]`);
const busy = _showEmailDeleteOverlay(card);
await busy?.ready;
try {
const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
} catch (e) {
busy?.remove?.();
showToast(e?.message || 'Could not move email to Trash');
return;
}
busy?.remove?.();
await _animateEmailCardRemoval([em.uid]);
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
_renderGrid();
_libCacheWriteBack();
}},
);
const orderedActions = orderActionMenuItems(actions);
let addedActionDivider = false;
for (const [index, a] of orderedActions.entries()) {
if (!addedActionDivider && index > 0 && actionMenuRank(a) >= 700) {
const divider = document.createElement('div');
divider.className = 'dropdown-divider';
dropdown.appendChild(divider);
addedActionDivider = true;
}
const item = document.createElement('div');
item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
const arrow = a.submenu ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${arrow}`;
item.addEventListener('click', (e) => {
e.stopPropagation();
if (a.submenu === 'remind') {
_showLibRemindSubmenu(em, dropdown);
return;
}
close();
a.action();
});
dropdown.appendChild(item);
}
// Mobile-only Cancel item — explicit close for touch users. CSS hides it
// on desktop where outside-click already dismisses cleanly.
const _cancelIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const cancelItem = document.createElement('div');
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
cancelItem.addEventListener('click', (e) => {
e.stopPropagation();
close();
});
dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
anchor.classList.remove('reader-more-active');
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
export function _showBulkActionsMenu(anchor) {
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown email-bulk-menu';
const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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 _exportIco = '<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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const items = [
{ label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() },
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
{ label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') },
];
for (const a of items) {
const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it);
}
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
const _cancelIco2 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const cancelIt = document.createElement('div');
cancelIt.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
cancelIt.addEventListener('click', (e) => {
e.stopPropagation();
close();
// Cancel inside the bulk-Actions menu also exits select mode — matches the
// documents bulk dropdown.
state._selectMode = false;
state._selectedUids.clear();
_updateBulkBar();
_renderGrid();
});
dropdown.appendChild(cancelIt);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
export function _updateBulkBar() {
const bar = document.getElementById('email-lib-bulk');
const grid = document.getElementById('email-lib-grid');
if (grid) grid.classList.toggle('email-selecting', !!state._selectMode);
const selectBtn = document.getElementById('email-lib-select-btn');
if (bar) bar.classList.toggle('hidden', !state._selectMode);
if (selectBtn) {
selectBtn.textContent = state._selectMode ? 'Cancel' : 'Select';
selectBtn.classList.toggle('active', state._selectMode);
}
const count = document.getElementById('email-lib-selected-count');
if (count) count.textContent = `${state._selectedUids.size} Selected`;
const all = document.getElementById('email-lib-select-all');
if (all) all.checked = state._libEmails.length > 0 && state._libEmails.every(e => state._selectedUids.has(e.uid));
// When something's selected, brighten Actions to the same full --fg color as
// the "N Selected" count (the button is a dimmer 60% --fg by default).
const actions = document.getElementById('email-lib-bulk-actions');
if (actions) actions.style.color = state._selectedUids.size > 0 ? 'var(--fg)' : '';
const deleteBtn = document.getElementById('email-lib-bulk-delete');
if (deleteBtn) deleteBtn.style.color = state._selectedUids.size > 0 ? 'var(--red)' : '';
}
export async function _bulkAction(action) {
const uids = Array.from(state._selectedUids);
if (uids.length === 0) return;
let failedReadSync = 0;
const failedUids = new Set();
if (action === 'delete') {
const ok = await styledConfirm(
`Delete ${uids.length} selected email${uids.length === 1 ? '' : 's'}?`,
{ confirmText: 'Delete', cancelText: 'Cancel', danger: true },
);
if (!ok) return;
// Deletion is intentionally optimistic. The user should not have to keep
// the mail window open while IMAP moves a batch; refresh and restore the
// cards only if the provider rejects the background operation.
const removed = new Set(uids.map(uid => String(uid)));
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
state._selectedUids.clear();
state._selectMode = false;
_updateBulkBar();
_renderGrid();
_libCacheWriteBack();
showToast(`Moved ${uids.length} email${uids.length === 1 ? '' : 's'} to Trash`);
fetch(`${API_BASE}/api/email/delete-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }),
}).then(async (res) => {
const data = await res.json().catch(() => null);
if (!res.ok || data?.success === false || (data?.failed_uids || []).length) {
throw new Error(data?.error || `${data?.failed_uids?.length || 0} emails could not be deleted`);
}
}).catch((error) => {
console.error('Background bulk email delete failed:', error);
showToast('Delete failed; restoring mailbox');
_loadEmailsFresh({ force: true, useCache: false, refreshAfterCache: false }).catch(() => {});
});
return;
}
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;
// Loading state for every bulk action, not just delete — large
// selections (e.g. 90+ Dones) used to silently hammer the server
// with sequential requests and the user got zero feedback. Now the
// Actions button (or Delete button) shows a whirlpool + verb-ing
// label, and the count surfaces progress.
const verbing = {
delete: 'Deleting',
archive: 'Archiving',
done: 'Marking done',
read: 'Marking read',
unread: 'Marking unread',
}[action] || 'Updating';
const targetBtn = action === 'delete' ? deleteBtn : actionsBtn;
let originalTargetHtml = '';
if (targetBtn) {
originalTargetHtml = targetBtn.innerHTML;
targetBtn.disabled = true;
targetBtn.classList.add('email-bulk-loading');
targetBtn.innerHTML = `<span class="email-bulk-loading-label">${verbing}</span>`;
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
const spEl = busySpinner.createElement();
spEl.classList.add('email-bulk-whirlpool');
targetBtn.appendChild(spEl);
busySpinner.start();
}
if (action !== 'delete' && deleteBtn) deleteBtn.disabled = true;
if (action === 'delete' && actionsBtn) actionsBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
if (countEl) countEl.textContent = `${verbing} ${uids.length}…`;
const deleteOverlays = action === 'delete'
? uids.map(uid => {
const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(uid))}"]`);
return _showEmailDeleteOverlay(card);
}).filter(Boolean)
: [];
if (deleteOverlays.length) {
await Promise.all(deleteOverlays.map(busy => busy.ready).filter(Boolean));
}
// Single-uid worker.
const handleOne = async (uid) => {
try {
if (action === 'archive') {
const res = await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
const data = await res.json().catch(() => null);
if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
} else if (action === 'delete') {
// Bulk Delete matches the permanent-delete behavior used by the
// library and albums. Moving a batch through provider Trash folders
// is unreliable on several IMAP implementations.
const res = await fetch(`${API_BASE}/api/email/delete-permanent/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
const data = await res.json().catch(() => null);
if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
} else if (action === 'done') {
// uid may come back from the Set as a string while em.uid is
// numeric (or vice versa) — coerce both sides so the in-memory
// state actually flips and the post-loop re-render shows the
// done checkmark.
const em = state._libEmails.find(e => String(e.uid) === String(uid));
if (em) {
em.is_answered = true;
em.is_read = true;
_clearDoneResponseTagsLocal(em);
}
const ansRes = await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
const readRes = await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
if (!ansRes.ok || !readRes.ok) throw new Error(`mark-done HTTP ${ansRes.status}/${readRes.status}`);
} 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;
failedUids.add(String(uid));
console.error(`Failed to ${action} ${uid}:`, e);
}
};
try {
// Run in parallel with a concurrency cap so 92 emails don't take
// 30 seconds sequentially but we also don't open 92 simultaneous
// connections.
const CONCURRENCY = 6;
const queue = uids.slice();
let inFlight = 0;
let nextSlot = 0;
let finishedCount = 0;
let bulkDeletedUids = null;
if (action === 'delete') {
const res = await fetch(`${API_BASE}/api/email/delete-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }),
});
const data = await res.json().catch(() => null);
if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
for (const uid of data?.failed_uids || []) failedUids.add(String(uid));
bulkDeletedUids = (data?.deleted_uids || []).map(String);
if (countEl) countEl.textContent = `${verbing} ${bulkDeletedUids.length}/${uids.length}…`;
} else await new Promise((resolve) => {
const launch = () => {
while (inFlight < CONCURRENCY && nextSlot < queue.length) {
const uid = queue[nextSlot++];
inFlight++;
handleOne(uid).finally(() => {
inFlight--;
finishedCount++;
if (countEl) countEl.textContent = `${verbing} ${finishedCount}/${queue.length}…`;
if (nextSlot >= queue.length && inFlight === 0) resolve();
else launch();
});
}
if (queue.length === 0) resolve();
};
launch();
});
if (action === 'archive' || action === 'delete') {
const successfulUids = bulkDeletedUids || uids.filter(uid => !failedUids.has(String(uid)));
if (action === 'delete') {
deleteOverlays.forEach(busy => busy.remove?.());
}
await _animateEmailCardRemoval(successfulUids);
const removed = new Set(successfulUids.map(uid => String(uid)));
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
} else if (action === 'done' && state._libFilter === 'undone') {
// The undone filter is a "show only not-done" view — after marking
// selected emails done, they no longer match. Animate them out and
// drop them from the local list so the view reflects the filter
// instead of leaving freshly-done cards sitting there.
await _animateEmailCardRemoval(uids);
const removed = new Set(uids.map(uid => String(uid)));
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
}
} finally {
deleteOverlays.forEach(busy => busy.remove?.());
if (busySpinner) busySpinner.destroy();
// Restore whichever button we hijacked (delete vs actions).
if (targetBtn) {
targetBtn.disabled = false;
targetBtn.classList.remove('email-bulk-loading');
targetBtn.innerHTML = originalTargetHtml || targetBtn.innerHTML;
}
if (deleteBtn && deleteBtn !== targetBtn) {
deleteBtn.disabled = false;
deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML;
}
if (actionsBtn && actionsBtn !== targetBtn) actionsBtn.disabled = false;
if (cancelBtn) cancelBtn.disabled = false;
if (selectAll) selectAll.disabled = false;
if (countEl) countEl.textContent = originalCountText;
}
state._selectedUids.clear();
state._selectMode = false;
_updateBulkBar();
_renderGrid();
if (failedReadSync > 0) {
showToast(`Failed to update ${failedReadSync} email${failedReadSync === 1 ? '' : 's'}`);
}
if (failedUids.size > 0 && (action === 'delete' || action === 'archive')) {
showToast(`${failedUids.size} email${failedUids.size === 1 ? '' : 's'} could not be ${action === 'delete' ? 'deleted' : 'archived'}`);
}
// Sync successful local mutations into the SWR cache so reopen doesn't
// briefly show the pre-bulk state.
_libCacheWriteBack();
}
+677
View File
@@ -0,0 +1,677 @@
// static/js/emailLibrary/reader.js
//
// The two ways an email opens outside the list: as a docked tab (a numbered
// slot in the dock, `_openEmailAsTab`) and as a floating window
// (`_openEmailWindow`). Plus the AI summary panel, which both of them and the
// inline expanded card share.
//
// Reader slot numbers are deliberately sticky: once a reader is tab 2 it stays
// tab 2 until it closes, even if tab 1 closes first. The slot map here is what
// holds that.
import spinnerModule from '../spinner.js';
import * as Modals from '../modalManager.js';
import { showToast } from '../ui.js?v=20260916largetoolscroll1';
import { state } from './state.js';
import { _esc, _extractName, _renderEmailSummaryError } from './utils.js';
import { _aiReplyIcon, _handleAiReplyButton, _hasMultipleRecipients } from './aiReply.js';
import {
_buildAttsHtmlFor,
_loadDeferredAttachmentsIntoReader,
_wireEmailAttachmentWrap,
} from './attachments.js';
import {
_renderEmailBody,
_safeRenderEmailBody,
_wireEmailInlineImages,
} from './bodyRender.js';
import {
_acct,
_emailReaderSkeletonHtml,
_makeDraggable,
_markEmailReaderActive,
_maybeAutoTranslateEmail,
_normalizeEmailStateFlags,
_recipientChipHtml,
_recipientMetaToggleHtml,
_setSummaryCollapsedPref,
_showEmailReaderLoadError,
_snapEmailModalToLeftSidebar,
_splitRecipientList,
_stampReaderContext,
_summaryCollapsedPref,
_syncEmailDoneState,
_syncEmailReadState,
_toggleCardPreview,
_wireMetaToggle,
_wireReaderActionOverflow,
_wireRecipientChips,
} from './index.js';
import { _showReaderMoreMenu } from './menus.js';
const API_BASE = window.location.origin;
// "Open in new tab" — the email opens in the library (expanded inline)
// AND a separate floating "email viewer" overlay modal is created. The
// overlay starts minimized as a chip in the dock; tapping the chip
// brings the viewer up over the library. Multiple tabs = multiple
// overlay modals + chips, each independent.
const _EMAIL_ICON_PATH = 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7';
let _emailTabSeq = 0;
// Persistent slot numbers per reader modalId. Once a reader is "tab 2"
// it stays "tab 2" until it's closed — even if tab 1 closes first, the
// remaining reader doesn't renumber down to 1. New tabs claim the
// lowest unused slot.
const _emailReaderSlots = new Map(); // modalId -> slot (1, 2, 3, ...)
function _allocReaderSlot(modalId) {
if (_emailReaderSlots.has(modalId)) return _emailReaderSlots.get(modalId);
const used = new Set(_emailReaderSlots.values());
let n = 1;
while (used.has(n)) n++;
_emailReaderSlots.set(modalId, n);
return n;
}
function _freeReaderSlot(modalId) {
_emailReaderSlots.delete(modalId);
}
// JS-driven gate: sets [data-email-tabs="N"] on <body> so CSS can show
// the per-chip number badge only when 2+ tabs exist.
function _syncEmailTabsCount() {
const tabs = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-view-"]');
document.body.dataset.emailTabs = String(tabs.length);
}
// Recompute the email menu chip's tab-count whenever the dock contents
// change. Counts "email-view-*" chips both inside #minimized-dock and
// at body level (free-positioned chips on mobile). Result is written to
// the email-lib-modal chip's data-tab-count attribute; CSS reads it via
// attr() to render the badge.
function _syncEmailTabBadge() {
const readers = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-reader-"]');
document.body.dataset.emailReaders = String(readers.length);
// Stamp each chip with its persistent slot number. CSS reads
// data-tab-num via attr() instead of using a counter so the number
// stays stable when other tabs close.
readers.forEach(chip => {
const slot = _emailReaderSlots.get(chip.dataset.modalId);
if (slot) chip.dataset.tabNum = String(slot);
});
}
let _emailTabObserverWired = false;
let _badgeSyncScheduled = false;
function _ensureEmailTabObserver() {
if (_emailTabObserverWired) return;
_emailTabObserverWired = true;
// Debounce so a burst of mutations (e.g. _renderDock rebuilding the
// whole dock in one pass) collapses to a single sync per animation
// frame. Without this the chip badge could flicker as the observer
// fires repeatedly during dock rerenders.
const handler = () => {
if (_badgeSyncScheduled) return;
_badgeSyncScheduled = true;
requestAnimationFrame(() => {
_badgeSyncScheduled = false;
_syncEmailTabBadge();
});
};
const tryWire = () => {
const dock = document.getElementById('minimized-dock');
if (!dock) { setTimeout(tryWire, 200); return; }
// Only watch what we care about: chip add/remove in the dock.
const obs = new MutationObserver(handler);
obs.observe(dock, { childList: true });
// Watch the library grid so toggling a card expanded/collapsed
// updates the lib chip's "has-expanded" badge in real time.
const wireGridObs = () => {
const grid = document.getElementById('email-lib-grid');
if (!grid) { setTimeout(wireGridObs, 500); return; }
const gridObs = new MutationObserver(handler);
gridObs.observe(grid, { subtree: true, attributes: true, attributeFilter: ['class'] });
};
wireGridObs();
handler();
};
tryWire();
}
// Hybrid model:
// - email-lib-modal (the inbox library) is unique. Its chip just
// restores it.
// - Each "Open in new tab" creates a separate per-email reader modal
// (id "email-reader-{uid}-{seq}") with the SAME structure & classes
// as the library's inline reader, so they look identical. Each
// reader registers its own dock chip with a number badge.
export async function _openEmailAsTab(em, folder) {
const useFolder = folder || state._libFolder || 'INBOX';
_emailTabSeq += 1;
const modalId = `email-reader-${em.uid}-${_emailTabSeq}`;
_allocReaderSlot(modalId);
// Build the modal shell. Uses the same doclib-modal-content sizing
// as the email library so it feels like a sibling window. The reader
// body inside uses the exact same email-card-reader / email-reader-*
// classes the inline reader uses → identical styling.
const modal = document.createElement('div');
modal.className = 'modal email-reader-tab-modal';
modal.id = modalId;
modal.innerHTML = `
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);display:flex;flex-direction:column;">
<div class="modal-header">
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
</h4>
<button class="minimize-btn" type="button" title="Minimize">_</button>
<button class="close-btn" type="button" title="Close">&#x2716;</button>
</div>
<div class="modal-body email-reader-tab-body" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;padding:0;">
<div class="email-card-reader email-card-expanded" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<div class="email-reader-tab-loading" style="padding:24px;display:flex;justify-content:center;"></div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Inherit display from .modal (flex-center). z-index above the library
// (which uses default .modal z-index 250) so the new tab sits on top.
modal.style.zIndex = '270';
// Opened last → email windows in front of any open doc (alternation flag).
document.body.classList.add('email-front');
Modals.register(modalId, {
label: 'Email',
icon: _EMAIL_ICON_PATH,
closeFn: () => {
modal.remove();
_freeReaderSlot(modalId);
Promise.resolve().then(_syncEmailTabBadge);
},
restoreFn: () => {
// Reopened last → bring the email windows in front of any open doc.
document.body.classList.add('email-front');
// Mobile: only one email window visible at a time. Tapping this
// chip chips down the library + any other reader, so the user
// toggles between them via the dock instead of stacking.
if (window.innerWidth <= 768) {
try {
if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
Modals.minimize('email-lib-modal');
}
} catch {}
document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
if (other.id === modalId) return;
try {
if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
Modals.minimize(other.id);
}
} catch {}
});
}
},
});
// Wire the `_` minimize button via modalManager (it sees our .minimize-btn
// already exists and just binds the click handler).
try { Modals.injectMinimizeButton(modal, modalId); } catch {}
// X button fully closes the tab (tears down and unregisters).
modal.querySelector('.close-btn')?.addEventListener('click', (ev) => {
ev.stopPropagation();
Modals.close(modalId);
});
// Wire dragging on the header (desktop only). Matches the global pattern
// in app.js initUIVisibility, but that runs once at boot and doesn't see
// dynamically-created modals — so we replicate it here.
const content = modal.querySelector('.modal-content');
const mh = modal.querySelector('.modal-header');
if (mh && content) {
let dragX = 0, dragY = 0, startLeft = 0, startTop = 0, dragging = false;
const startDrag = (clientX, clientY) => {
dragging = true;
const rect = content.getBoundingClientRect();
dragX = clientX; dragY = clientY;
startLeft = rect.left; startTop = rect.top;
content.style.position = 'fixed';
content.style.left = startLeft + 'px';
content.style.top = startTop + 'px';
content.style.margin = '0';
};
const onDrag = (e) => {
if (!dragging) return;
content.style.left = (startLeft + e.clientX - dragX) + 'px';
content.style.top = (startTop + e.clientY - dragY) + 'px';
};
const stopDrag = () => {
dragging = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
};
mh.addEventListener('mousedown', (e) => {
if (e.target.closest('.close-btn, .minimize-btn, .modal-minimize-btn')) return;
e.preventDefault();
startDrag(e.clientX, e.clientY);
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
});
}
// Open the new tab in front, on top of the email library. The user
// can tap `_` to tab it down to a chip when they're done reading.
//
// Mobile: bottom-sheet windows fill the viewport, so stacking multiple
// readers on top of each other is confusing — only one window can be
// meaningfully visible at a time. So when the new tab opens, chip down
// the library AND any other email-reader-* tab that's currently up.
// The user gets a stack of mini chips to toggle between them.
if (window.innerWidth <= 768) {
try {
if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
Modals.minimize('email-lib-modal');
}
} catch {}
document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
if (other.id === modalId) return;
try {
if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
Modals.minimize(other.id);
}
} catch {}
});
}
_ensureEmailTabObserver();
_syncEmailTabBadge();
// Fetch + render the email body using the exact same template as
// _toggleCardPreview so the visuals match perfectly.
const reader = modal.querySelector('.email-card-reader');
const showFailedTab = (message) => {
try { showToast(message || 'Failed to load email'); } catch (_) {}
_showEmailReaderLoadError(reader, message, () => {
try { Modals.close(modalId); } catch (_) { try { modal.remove(); } catch (_) {} }
setTimeout(() => { _openEmailAsTab(em, useFolder); }, 0);
});
};
_markEmailReaderActive(reader);
const loading = modal.querySelector('.email-reader-tab-loading');
if (loading) loading.remove();
if (reader) {
reader.classList.add('email-card-reader-loading');
reader.innerHTML = _emailReaderSkeletonHtml();
}
try {
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
let data = await res.json();
if (data.error) {
showFailedTab(`Failed to load email: ${data.error}`);
return;
}
data = _normalizeEmailStateFlags({ ...em, ...data });
Object.assign(em, data);
_syncEmailDoneState(em.uid, data.is_answered);
_syncEmailReadState(em.uid, true);
_stampReaderContext(reader, data, useFolder, state._libAccountId);
const buildChips = (str) => {
if (!str) return '';
return _splitRecipientList(str).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');
let attsHtml = '';
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
reader.innerHTML = `
<div class="email-reader-header">
<div class="email-reader-meta">
<div class="email-reader-meta-row email-reader-meta-from">
<strong>From:</strong>
<span class="recipient-chips">${fromChip}${_recipientMetaToggleHtml(data)}</span>
</div>
${(data.to || data.cc) ? `<div class="email-reader-meta-details" hidden>
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${buildChips(data.to)}</span></div>` : ''}
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildChips(data.cc)}</span></div>` : ''}
</div>` : ''}
<div class="email-reader-actions-inline">
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
<div class="email-reader-more-wrap" style="position:relative">
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
</div>
</div>
</div>
</div>
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
_markEmailReaderActive(reader);
reader.classList.remove('email-card-reader-loading');
_wireRecipientChips(reader);
_wireEmailAttachmentWrap(reader, useFolder);
_wireEmailInlineImages(reader);
_loadDeferredAttachmentsIntoReader(reader, em.uid, useFolder, data, !!em.has_attachments);
_maybeAutoTranslateEmail(reader);
reader.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
});
reader.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
});
reader.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
reader.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
});
reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
try { await _summarizeEmail(reader, data, ev.currentTarget); } catch {}
});
_wireMetaToggle(reader);
_wireReaderActionOverflow(reader);
reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
ev.stopPropagation();
try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget, data); } catch {}
});
} catch (err) {
showFailedTab(err?.message ? `Failed to load email: ${err.message}` : 'Failed to load email');
}
}
// "Open in new window" — spawns a floating draggable modal that shows just
// the email content. Multiple windows can stack; each has its own DOM id
// and close button. Uses `_makeDraggable` so dragging the header pans the
// window around. Renders the body via _renderEmailBody for parity with the
// expanded reader.
let _emailWindowSeq = 0;
export async function _openEmailWindow(em, folder) {
const useFolder = folder || state._libFolder || 'INBOX';
_emailWindowSeq += 1;
const winId = `email-window-${em.uid}-${_emailWindowSeq}`;
const modal = document.createElement('div');
modal.className = 'modal email-window-modal';
modal.id = winId;
modal.style.cssText = 'pointer-events:none;background:transparent;';
modal.innerHTML = `
<div class="modal-content email-window-content" style="width:min(640px, 92vw);display:flex;flex-direction:column;background:var(--bg);">
<div class="modal-header">
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
<span class="email-window-subject" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(em.subject || '(no subject)')}</span>
</h4>
<button class="close-btn" type="button" title="Close">&#x2716;</button>
</div>
<div class="modal-body email-window-body" style="overflow:auto;padding:14px 16px;flex:1;min-height:0;">
<div class="email-window-loading" style="display:flex;justify-content:center;padding:24px;"></div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
const content = modal.querySelector('.modal-content');
// Position offset from screen center so successive windows cascade.
const isMobile = window.innerWidth <= 768;
if (isMobile) {
content.style.position = 'fixed';
content.style.pointerEvents = 'auto';
content.style.left = '0';
content.style.right = '0';
content.style.bottom = '0';
content.style.top = 'auto';
} else {
content.style.position = 'fixed';
content.style.pointerEvents = 'auto';
requestAnimationFrame(() => {
const w = content.offsetWidth, h = content.offsetHeight;
const off = (_emailWindowSeq % 6) * 28;
content.style.left = Math.max(20, (window.innerWidth - w) / 2 + off) + 'px';
content.style.top = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px';
});
}
modal.querySelector('.close-btn')?.addEventListener('click', () => modal.remove());
try { _makeDraggable(content, modal, 'email-window-fullscreen'); } catch {}
// Load + render
const bodyEl = modal.querySelector('.email-window-body');
const loading = modal.querySelector('.email-window-loading');
try {
if (loading) loading.remove();
if (bodyEl) {
bodyEl.classList.add('email-card-reader', 'email-card-reader-loading');
bodyEl.style.padding = '0';
bodyEl.innerHTML = _emailReaderSkeletonHtml();
}
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
let data = await res.json();
if (data.error) {
bodyEl.innerHTML = `<div style="color:var(--red,#e55);padding:16px;">${_esc(data.error)}</div>`;
return;
}
data = _normalizeEmailStateFlags({ ...em, ...data });
Object.assign(em, data);
_syncEmailDoneState(em.uid, data.is_answered);
_syncEmailReadState(em.uid, true);
const subjEl = modal.querySelector('.email-window-subject');
if (subjEl && data.subject) subjEl.textContent = data.subject;
// Build recipient chips the same way the inline reader does so the
// standalone viewer looks/feels exactly like a real email view.
const _chipsFor = (addrs) => {
if (!addrs) return '';
const list = _splitRecipientList(addrs);
return list.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');
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');
bodyEl.classList.remove('email-card-reader-loading');
_stampReaderContext(bodyEl, { ...em, ...data }, useFolder, state._libAccountId);
_markEmailReaderActive(bodyEl);
bodyEl.style.padding = '0';
bodyEl.innerHTML = `
<div class="email-reader-header">
<div class="email-reader-meta">
<div class="email-reader-meta-row email-reader-meta-from">
<strong>From:</strong>
<span class="recipient-chips">${fromChip}${_recipientMetaToggleHtml(data)}</span>
</div>
${(data.to || data.cc) ? `<div class="email-reader-meta-details" hidden>
${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>` : ''}
</div>` : ''}
<div class="email-reader-actions-inline">
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
<div class="email-reader-more-wrap" style="position:relative">
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
</div>
</div>
</div>
</div>
${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.
_wireEmailAttachmentWrap(bodyEl, useFolder);
_wireEmailInlineImages(bodyEl);
_loadDeferredAttachmentsIntoReader(bodyEl, em.uid, useFolder, data, !!em.has_attachments);
_maybeAutoTranslateEmail(bodyEl);
bodyEl.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
});
bodyEl.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
});
bodyEl.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
bodyEl.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
});
bodyEl.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
try { await _summarizeEmail(bodyEl, data, ev.currentTarget); } catch {}
});
_wireMetaToggle(bodyEl);
_wireReaderActionOverflow(bodyEl);
bodyEl.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
ev.stopPropagation();
// Use a synthetic "card" — the more-menu only needs the anchor
// element and the email data. The card param is mostly used to find
// the next sibling; the standalone window has none so we just pass
// bodyEl as a stand-in.
try { _showReaderMoreMenu(em, modal, bodyEl, ev.currentTarget, data); } catch {}
});
} catch (err) {
bodyEl.innerHTML = `<div style="color:var(--red,#e55);padding:16px;">Failed to load: ${_esc(String(err))}</div>`;
}
}
export async function _summarizeEmail(reader, data, btn) {
const body = reader.querySelector('.email-reader-body');
if (!body) return;
// If a summary panel already exists, toggle: hide/show
const existing = body.querySelector('.email-summary-panel');
if (existing) {
if (existing.style.display === 'none') {
existing.style.display = '';
if (btn) {
btn.classList.add('active');
btn.querySelector('.btn-label').textContent = 'Summary';
}
} else {
existing.style.display = 'none';
if (btn) {
btn.classList.remove('active');
btn.querySelector('.btn-label').textContent = 'Summary';
}
}
return;
}
// No panel yet. If the email has no cached AI summary, show a placeholder
// "not generated — create now?" prompt instead of firing the LLM immediately.
// This avoids accidental LLM spend and makes the state explicit to the user.
if (!data.cached_summary) {
const prompt = document.createElement('div');
prompt.className = 'email-summary-panel';
prompt.innerHTML = `
<div class="email-summary-header">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
<span>Summary</span>
</div>
<div class="email-summary-content" style="white-space:normal;display:flex;align-items:center;flex-wrap:wrap;gap:6px;"><span style="opacity:0.65">No AI summary generated.</span><button class="memory-toolbar-btn" data-act="summary-generate" style="font-size:10px;margin-left:auto;">Generate now</button></div>`;
body.insertBefore(prompt, body.firstChild);
if (btn) {
btn.classList.add('active');
const label = btn.querySelector('.btn-label');
if (label) label.textContent = 'Summary';
}
// No Cancel button — toggling the Summary button again hides this panel
// (handled by the existing-panel branch above), so it'd be redundant.
prompt.querySelector('[data-act="summary-generate"]').addEventListener('click', async (ev) => {
ev.stopPropagation();
prompt.remove();
await _generateSummary(reader, data, btn);
});
return;
}
// Cached summary exists — show it immediately.
await _generateSummary(reader, data, btn);
}
async function _generateSummary(reader, data, btn) {
const body = reader.querySelector('.email-reader-body');
if (!body) return;
const panel = document.createElement('div');
panel.className = 'email-summary-panel';
panel.innerHTML =
'<div class="email-summary-header email-summary-toggle" role="button" tabindex="0">'
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>'
+ '<span>Summary</span>'
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</div>'
+ '<div class="email-summary-content"></div>';
if (_summaryCollapsedPref()) panel.classList.add('collapsed');
body.insertBefore(panel, body.firstChild);
const _genToggle = panel.querySelector('.email-summary-toggle');
if (_genToggle) {
const _genFlip = () => {
panel.classList.toggle('collapsed');
_setSummaryCollapsedPref(panel.classList.contains('collapsed'));
};
_genToggle.addEventListener('click', (ev) => { ev.stopPropagation(); _genFlip(); });
_genToggle.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _genFlip(); }
});
}
const sp = spinnerModule.createWhirlpool(18);
const content = panel.querySelector('.email-summary-content');
content.appendChild(sp.element);
if (btn) btn.disabled = true;
try {
const res = await fetch(`${API_BASE}/api/email/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
body: data.body,
subject: data.subject,
from: `${data.from_name} <${data.from_address}>`,
// Send identifiers so the backend can fetch the raw message and
// pull attachment text for the summary (PDFs, invoices, etc.).
uid: data.uid || '',
folder: state._libFolder || 'INBOX',
message_id: data.message_id || '',
account_id: data.account_id || '',
}),
});
const result = await res.json();
sp.destroy();
content.innerHTML = '';
if (result.success && result.summary) {
content.textContent = result.summary;
if (btn) {
btn.classList.add('active');
const label = btn.querySelector('.btn-label');
if (label) label.textContent = 'Summary';
}
} else {
_renderEmailSummaryError(content, result);
}
} catch (e) {
sp.destroy();
_renderEmailSummaryError(content, null);
if (uiModule) uiModule.showError?.('Failed to summarize');
} finally {
if (btn) btn.disabled = false;
}
}
export function _emailBodyTextForTranslate(reader) {
const body = reader?.querySelector?.('.email-reader-body');
if (!body) return '';
const clone = body.cloneNode(true);
clone.querySelectorAll('.email-summary-panel, details.email-quote-fold, details.email-sig-fold').forEach(n => n.remove());
return (clone.innerText || clone.textContent || '').trim();
}
+880
View File
@@ -0,0 +1,880 @@
// static/js/emailLibrary/settingsPage.js
//
// The Email Settings page: the in-panel settings view, its form markup and
// controls, the away/auto-reply configuration, and the display preferences
// (inline images, tag visibility, compact rows).
//
// Two things here reach outside settings, which is why they live together.
// Turning away-replies on writes a matching calendar event, so this module owns
// the `odysseus.email.autoReplyCalendarEvent.*` keys and the create/update/
// delete sync against /api/calendar. And the inline-image preference is read by
// ./bodyRender.js while rendering a message, not only by the settings form.
import spinnerModule from '../spinner.js';
import { emailApiUrl } from '../emailShared.js';
import { showToast } from '../ui.js?v=20260916largetoolscroll1';
import { state } from './state.js';
import { _esc } from './utils.js';
import { _wireEmailInlineImages } from './bodyRender.js';
import {
_loadAccounts,
_loadEmails,
_loadFolders,
_loadedEmailsHaveVisibleTags,
_notifyNoLoadedEmailTags,
_openTasksForEmailTags,
_publishActiveAccount,
_refreshUnreadBadge,
_renderAccountsStrip,
_renderGrid,
_resetEmailListForFreshLoad,
} from './index.js';
import { _openUnsubscribeReviewModal } from './unsubscribe.js';
const API_BASE = window.location.origin;
function _emailInlineImagesStorageKey(accountId = state._libAccountId) {
return `odysseus.email.viewInlineImages.${String(accountId || 'default')}`;
}
export function _readEmailInlineImagesPreference(accountId = state._libAccountId) {
try {
const stored = localStorage.getItem(_emailInlineImagesStorageKey(accountId));
return stored === null ? true : stored !== '0';
} catch (_) {
return true;
}
}
function _writeEmailInlineImagesPreference(enabled, accountId = state._libAccountId) {
try {
localStorage.setItem(_emailInlineImagesStorageKey(accountId), enabled ? '1' : '0');
} catch (_) {}
}
export async function _disableInlineImages(accountId = state._libAccountId) {
state._libViewInlineImages = false;
_writeEmailInlineImagesPreference(false, accountId);
document.querySelectorAll('#email-settings-view-inline-images').forEach(toggle => {
toggle.checked = true;
toggle.dispatchEvent(new Event('change', { bubbles: true }));
});
try {
const res = await fetch(emailApiUrl('/api/email/config', {
account_id: accountId || undefined,
}), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email_view_inline_images: false }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
console.error('Failed to disable inline images:', err);
}
}
export const _EMAIL_SETTINGS_ICON = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>`;
const _EMAIL_SAVE_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>';
const _EMAIL_SAVED_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
function _setEmailSaveIcon(button, saved) {
if (!button) return;
const icon = button.querySelector('svg');
if (icon) icon.outerHTML = saved ? _EMAIL_SAVED_ICON : _EMAIL_SAVE_ICON;
button.classList.toggle('is-saved', !!saved);
}
const _DEFAULT_AUTO_REPLY_SUBJECT = '(Away) {subject}';
const _DEFAULT_AUTO_REPLY_MESSAGE = "Thanks for your email. I'm away and may be slower to reply.";
function _normalizeAutoReplyConfig(cfg = {}) {
const normalized = Object.prototype.hasOwnProperty.call(cfg, 'enabled');
return {
enabled: normalized ? !!cfg.enabled : !!cfg.email_auto_reply,
start: String(normalized ? (cfg.start || '') : (cfg.email_auto_reply_start || '')),
end: String(normalized ? (cfg.end || '') : (cfg.email_auto_reply_end || '')),
subject: String(normalized ? (cfg.subject || _DEFAULT_AUTO_REPLY_SUBJECT) : (cfg.email_auto_reply_subject || _DEFAULT_AUTO_REPLY_SUBJECT)),
message: String(normalized ? (cfg.message || '') : (cfg.email_auto_reply_message || '')),
cooldown: String(normalized ? (cfg.cooldown || 'period') : (cfg.email_auto_reply_cooldown || 'period')),
scope: String(normalized ? (cfg.scope || 'all') : (cfg.email_auto_reply_scope || 'all')),
accountId: String(normalized ? (cfg.accountId || '') : (cfg.email_auto_reply_account_id || '')),
excludeAutomated: normalized ? cfg.excludeAutomated !== false : cfg.email_auto_reply_exclude_automated !== false,
pauseNotifications: normalized ? !!cfg.pauseNotifications : !!cfg.email_auto_reply_pause_notifications,
viewInlineImages: normalized ? cfg.viewInlineImages !== false : cfg.email_view_inline_images !== false,
};
}
function _autoReplyDateValue(value) {
const s = String(value || '').trim();
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : '';
}
function _todayDateInputValue() {
const now = new Date();
const pad = value => String(value).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
const _AUTO_REPLY_CALENDAR_KEY_PREFIX = 'odysseus.email.autoReplyCalendarEvent.';
const _autoReplyCalendarSyncs = new Map();
function _autoReplyCalendarKey(accountId) {
return `${_AUTO_REPLY_CALENDAR_KEY_PREFIX}${String(accountId || '').trim()}`;
}
function _readAutoReplyCalendarUid(accountId) {
if (!accountId) return '';
try { return String(localStorage.getItem(_autoReplyCalendarKey(accountId)) || ''); } catch (_) { return ''; }
}
function _writeAutoReplyCalendarUid(accountId, uid) {
if (!accountId) return;
try {
if (uid) localStorage.setItem(_autoReplyCalendarKey(accountId), uid);
else localStorage.removeItem(_autoReplyCalendarKey(accountId));
} catch (_) {}
}
function _addLocalCalendarDays(value, days) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return '';
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days);
const pad = n => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function _autoReplyCalendarPayload(cfg, accountId = state._libAccountId) {
const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue();
let end = _autoReplyDateValue(cfg?.end) || start;
if (end < start) end = start;
return {
summary: 'Email Auto Reply (away)',
dtstart: start,
dtend: _addLocalCalendarDays(end, 1),
all_day: true,
description: `Odysseus email auto reply - account:${String(accountId || '')}`,
};
}
async function _findAutoReplyCalendarEventUids(cfg, accountId) {
const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue();
const end = _autoReplyDateValue(cfg?.end) || start;
const startYear = Number(start.slice(0, 4)) || new Date().getFullYear();
const endYear = Math.max(startYear, Number(end.slice(0, 4)) || startYear);
try {
const response = await fetch(`${API_BASE}/api/calendar/events?start=${startYear - 1}-01-01&end=${endYear + 2}-01-01`, {
credentials: 'same-origin',
});
if (!response.ok) return [];
const data = await response.json().catch(() => ({}));
const marker = `Odysseus email auto reply - account:${String(accountId || '')}`;
return (data.events || [])
.filter(ev => String(ev?.description || '').startsWith(marker))
.map(ev => String(ev?.uid || '').trim())
.filter(Boolean);
} catch (_) {
return [];
}
}
async function _syncAutoReplyCalendarEventNow(cfg, accountId) {
accountId = String(accountId || '').trim();
if (!accountId) return false;
let uid = _readAutoReplyCalendarUid(accountId);
const headers = { 'Content-Type': 'application/json' };
const request = (url, options = {}) => fetch(url, {
credentials: 'same-origin',
...options,
headers: { ...headers, ...(options.headers || {}) },
});
if (!cfg?.enabled) {
const knownUids = new Set(uid ? [uid] : []);
(await _findAutoReplyCalendarEventUids(cfg, accountId)).forEach(foundUid => knownUids.add(foundUid));
for (const eventUid of knownUids) {
const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(eventUid)}`, { method: 'DELETE' });
if (!response.ok && response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`);
}
_writeAutoReplyCalendarUid(accountId, '');
return true;
}
const payload = _autoReplyCalendarPayload(cfg, accountId);
if (!uid) {
uid = (await _findAutoReplyCalendarEventUids(cfg, accountId))[0] || '';
if (uid) _writeAutoReplyCalendarUid(accountId, uid);
}
if (uid) {
const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, {
method: 'PUT', body: JSON.stringify(payload),
});
if (response.ok) return true;
if (response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`);
_writeAutoReplyCalendarUid(accountId, '');
}
const response = await request(`${API_BASE}/api/calendar/events`, {
method: 'POST', body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data.uid) throw new Error(`Calendar HTTP ${response.status}`);
_writeAutoReplyCalendarUid(accountId, data.uid);
return true;
}
export function _syncAutoReplyCalendarEvent(cfg) {
const accountId = String(state._libAccountId || '').trim();
if (!accountId) return Promise.resolve(false);
const previous = _autoReplyCalendarSyncs.get(accountId) || Promise.resolve();
const next = previous.catch(() => {}).then(() => _syncAutoReplyCalendarEventNow(cfg, accountId));
_autoReplyCalendarSyncs.set(accountId, next);
return next.finally(() => {
if (_autoReplyCalendarSyncs.get(accountId) === next) _autoReplyCalendarSyncs.delete(accountId);
});
}
export function _isAutoReplyActiveForCurrentAccount(cfg) {
cfg = _normalizeAutoReplyConfig(cfg || {});
if (!cfg.enabled) return false;
if (cfg.scope === 'account' && cfg.accountId && cfg.accountId !== String(state._libAccountId || '')) return false;
const today = new Date().toISOString().slice(0, 10);
const start = _autoReplyDateValue(cfg.start);
const end = _autoReplyDateValue(cfg.end);
if (start && today < start) return false;
if (end && today > end) return false;
return true;
}
export function _syncEmailAutoReplyTitle(active) {
const titleText = document.getElementById('email-lib-title-text');
const statusDot = document.getElementById('email-lib-auto-reply-status-dot');
const badge = document.getElementById('email-lib-auto-reply-badge');
if (titleText) titleText.textContent = active ? 'Auto Reply - Active' : 'Email';
if (statusDot) statusDot.style.display = active ? 'inline-block' : 'none';
if (badge) {
badge.style.display = 'none';
badge.textContent = 'Auto Reply';
}
}
export async function _fetchEmailSettingsConfig() {
const res = await fetch(emailApiUrl('/api/email/config', {
account_id: state._libAccountId || undefined,
}), { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const normalized = _normalizeAutoReplyConfig(await res.json());
state._libViewInlineImages = normalized.viewInlineImages;
_writeEmailInlineImagesPreference(state._libViewInlineImages);
return normalized;
}
export async function _fetchEmailWritingStyle() {
const res = await fetch(emailApiUrl('/api/email/style', {
account_id: state._libAccountId || undefined,
}), { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json().catch(() => ({}));
return String(data.style || '');
}
export function _emailSettingsLoadingHtml(label = 'Loading') {
const wp = spinnerModule.createWhirlpool(16);
const host = document.createElement('span');
host.className = 'email-settings-loading-spinner';
host.appendChild(wp.element);
const wrap = document.createElement('div');
wrap.className = 'email-settings-loading';
wrap.appendChild(host);
const text = document.createElement('span');
text.textContent = label;
wrap.appendChild(text);
return wrap.outerHTML;
}
export function _emailSettingsFormHtml(cfg) {
const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId);
const accountAddress = activeAccount
? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim()
: '';
const accountScope = accountAddress
? ` <span class="email-settings-account-scope">(${_esc(accountAddress)})</span>`
: '';
return `
<div class="admin-card email-settings-section email-settings-auto-reply-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-auto-reply-title-row">
<div class="email-settings-section-title"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span>Auto Reply${accountScope}</span></div>
<div class="email-settings-enabled-control">
<span class="email-settings-enabled-state"></span>
<label class="email-settings-toggle admin-switch">
<input type="checkbox" id="email-auto-reply-enabled" ${cfg.enabled ? 'checked' : ''}>
<span class="admin-slider"></span>
</label>
</div>
</div>
<div class="email-settings-section-desc">Holiday or away replies for incoming mail.</div>
</div>
</div>
<div id="email-settings-auto-reply-body" class="email-settings-section-body">
<div class="email-settings-grid">
<label><span>Start date</span><input type="date" id="email-auto-reply-start" value="${_esc(_autoReplyDateValue(cfg.start))}"></label>
<label><span>End date</span><input type="date" id="email-auto-reply-end" value="${_esc(_autoReplyDateValue(cfg.end))}"></label>
</div>
<label class="email-settings-field">
<span>Subject</span>
<input type="text" id="email-auto-reply-subject" value="${_esc(cfg.subject || _DEFAULT_AUTO_REPLY_SUBJECT)}" placeholder="${_esc(_DEFAULT_AUTO_REPLY_SUBJECT)}">
</label>
<label class="email-settings-field">
<span>Message</span>
<textarea id="email-auto-reply-message" rows="4">${_esc(cfg.message || _DEFAULT_AUTO_REPLY_MESSAGE)}</textarea>
</label>
<label class="email-settings-field">
<span>Send to same sender</span>
<select id="email-auto-reply-cooldown">
<option value="period" ${cfg.cooldown === 'period' ? 'selected' : ''}>Once while active</option>
<option value="1d" ${cfg.cooldown === '1d' ? 'selected' : ''}>Every day</option>
<option value="3d" ${cfg.cooldown === '3d' ? 'selected' : ''}>Every 3 days</option>
<option value="7d" ${cfg.cooldown === '7d' ? 'selected' : ''}>Every 7 days</option>
</select>
</label>
<label class="email-settings-check">
<span>Pause email notifications while active</span>
<span class="email-settings-check-spacer"></span>
<span class="admin-switch"><input type="checkbox" id="email-auto-reply-pause" ${cfg.pauseNotifications ? 'checked' : ''}><span class="admin-slider"></span></span>
</label>
</div>
</div>
`;
}
export function _emailCleanupSettingsHtml() {
const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId);
const accountAddress = activeAccount
? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim()
: '';
const accountScope = accountAddress
? ` <span class="email-settings-account-scope">(${_esc(accountAddress)})</span>`
: '';
return `
<div class="admin-card email-settings-section email-settings-cleanup-section">
<div class="email-settings-section-head">
<div>
<span class="email-settings-section-title"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg><span>Newsletter Unsubscribe${accountScope}</span></span>
<div class="email-settings-section-desc">Find newsletter and ad emails with unsubscribe options.</div>
</div>
</div>
<div id="email-settings-cleanup-body" class="email-settings-section-body">
<div class="email-settings-clean-actions">
<span class="email-unsub-status email-settings-clean-status" style="font-size:12px;color:color-mix(in srgb,var(--fg) 68%,transparent);"></span>
<button type="button" class="memory-toolbar-btn email-settings-clean-btn">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="vertical-align:-1px;margin-right:2px;color:var(--accent,var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
<span>Clean</span>
</button>
<button type="button" class="memory-toolbar-btn email-settings-save">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
Save
</button>
</div>
</div>
</div>
`;
}
export function _emailDisplaySettingsHtml(cfg = {}) {
return `
<div class="admin-card email-settings-section email-settings-display-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-display-title-row">
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.59 3H4a1 1 0 0 0-1 1v5.59A2 2 0 0 0 3.59 11l9.59 9.59a2 2 0 0 0 2.82 0l4.59-4.59a2 2 0 0 0 0-2.82Z"/><path d="M7 7h.01"/></svg><span>Show Email Tags</span></div>
<span class="email-settings-enabled-control">
<span class="email-settings-enabled-state email-settings-display-enabled-state"></span>
<label class="email-settings-toggle admin-switch">
<input type="checkbox" id="email-settings-show-tags" ${state._libShowTags ? 'checked' : ''}>
<span class="admin-slider"></span>
</label>
</span>
</div>
<div class="email-settings-section-desc email-settings-support-copy">Choose which labels appear on email cards. <span class="email-settings-display-task-note">Turn off tagging in <button type="button" class="email-settings-inline-link" data-open-email-tasks>Tasks</button></span></div>
</div>
</div>
<div class="email-settings-display-spacer" aria-hidden="true"></div>
</div>
<div class="admin-card email-settings-section email-settings-inline-images-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-display-title-row">
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg><span>Hide Inline Images</span></div>
<span class="email-settings-enabled-control">
<span class="email-settings-enabled-state email-settings-inline-images-enabled-state"></span>
<label class="email-settings-toggle admin-switch">
<input type="checkbox" id="email-settings-view-inline-images" ${cfg.viewInlineImages === false ? 'checked' : ''}>
<span class="admin-slider"></span>
</label>
</span>
</div>
<div class="email-settings-section-desc email-settings-support-copy">Keep images embedded in email bodies hidden until you load them.</div>
</div>
</div>
<div class="email-settings-inline-images-spacer" aria-hidden="true"></div>
</div>
`;
}
export function _emailSettingsAccountSelectHtml() {
const accounts = Array.isArray(state._libAccounts)
? state._libAccounts
.filter(a => a && a.enabled !== false)
.slice()
.sort((a, b) => Number(!!b.is_default) - Number(!!a.is_default))
: [];
if (!accounts.length) return '';
const nameCounts = new Map();
accounts.forEach(a => {
const name = String(a.name || '').trim();
if (name) nameCounts.set(name, (nameCounts.get(name) || 0) + 1);
});
const opts = accounts.map(a => {
const name = String(a.name || '').trim();
const address = String(a.from_address || a.imap_user || '').trim();
const label = name && nameCounts.get(name) > 1 && address
? `${name} · ${address}`
: (name || address || 'account');
const suffix = a.is_default ? ' · (default)' : '';
return `<option value="${_esc(a.id || '')}" ${state._libAccountId === a.id ? 'selected' : ''}>${_esc(label + suffix)}</option>`;
}).join('');
const selected = accounts.find(a => a.id === state._libAccountId) || accounts[0];
const active = !!selected?.is_default;
const icon = active
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>'
: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
const defaultTitle = active ? 'Default account' : 'Make this the default account';
return `<div class="email-settings-account-picker" style="margin-left:auto;display:inline-flex;align-items:center;gap:8px;">`
+ `<span class="cookbook-srv-default email-settings-default${active ? ' active' : ''}" role="button" tabindex="0" aria-pressed="${active ? 'true' : 'false'}" data-email-default-id="${_esc(selected?.id || '')}" title="${defaultTitle}"><span class="cookbook-srv-default-icon" aria-hidden="true">${icon}</span><span class="cookbook-srv-default-label">default</span></span>`
+ `<select id="email-settings-account-select" class="memory-toolbar-btn" title="Active email for all email settings" style="height:28px;max-width:240px;position:relative;top:-2px;">${opts}</select>`
+ `</div>`;
}
export function _syncEmailSettingsAccountPicker(page) {
const select = page?.querySelector('#email-settings-account-select');
const selected = (state._libAccounts || []).find(a => a && a.id === state._libAccountId);
if (select && selected) {
[...select.options]
.sort((a, b) => {
const aAccount = (state._libAccounts || []).find(account => account && account.id === a.value);
const bAccount = (state._libAccounts || []).find(account => account && account.id === b.value);
return Number(!!bAccount?.is_default) - Number(!!aAccount?.is_default);
})
.forEach(option => select.appendChild(option));
select.value = selected.id;
[...select.options].forEach(option => {
const account = (state._libAccounts || []).find(a => a && a.id === option.value);
if (!account) return;
const label = account.name || account.from_address || account.imap_user || 'account';
option.textContent = label + (account.is_default ? ' · (default)' : '');
});
}
const toggle = page?.querySelector('.email-settings-default');
if (!toggle || !selected) return;
const active = !!selected.is_default;
toggle.classList.toggle('active', active);
toggle.setAttribute('aria-pressed', active ? 'true' : 'false');
toggle.dataset.emailDefaultId = selected.id || '';
toggle.title = active ? 'Default account' : 'Make this the default account';
toggle.querySelector('.cookbook-srv-default-icon').innerHTML = active
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>'
: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
}
export function _emailWritingStyleHtml(style) {
return `
<div class="admin-card email-settings-section email-settings-style-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg><span>Email Reply Writing Style</span></div>
<div class="email-settings-section-desc email-settings-support-copy">Used when Odysseus drafts replies for you.</div>
</div>
</div>
<div id="email-settings-style-body" class="email-settings-section-body">
<label class="email-settings-field">
<span>Writing style</span>
<textarea id="email-writing-style-text" rows="6">${_esc(style)}</textarea>
</label>
<div class="email-settings-actions">
<button type="button" class="memory-toolbar-btn email-style-settings-extract">
<svg class="email-unsub-accent-icon" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
Extract
</button>
<button class="email-style-extract-help" aria-label="What does Extract do?" title="Extract analyzes sent emails from the selected account and saves a writing-style prompt for AI replies.">?</button>
<button type="button" class="memory-toolbar-btn email-style-settings-save">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
Save
</button>
</div>
</div>
</div>
`;
}
export async function _showEmailSettingsPage() {
const modal = document.getElementById('email-lib-modal');
const page = document.getElementById('email-lib-settings-page');
const btn = document.getElementById('email-lib-settings-btn');
if (!modal || !page) return;
modal.classList.add('email-settings-mode');
page.hidden = false;
page.scrollTop = 0;
page.querySelector('.email-settings-body')?.scrollTo(0, 0);
const headerBackBtn = document.getElementById('email-settings-header-back');
if (headerBackBtn) headerBackBtn.style.display = 'inline-flex';
btn?.classList.add('active');
btn?.setAttribute('aria-expanded', 'true');
page.innerHTML = `
<div class="email-settings-page-head">
<h2 class="email-settings-title">${_EMAIL_SETTINGS_ICON}<span>Email Settings</span></h2>
</div>
<div class="email-settings-body">
${_emailSettingsLoadingHtml()}
</div>
`;
let cfg;
let writingStyle = '';
try {
await _loadAccounts().catch(() => {});
const head = page.querySelector('.email-settings-page-head');
const accountSelectHtml = _emailSettingsAccountSelectHtml();
const oldPicker = page.querySelector('.email-settings-account-picker');
if (oldPicker) oldPicker.remove();
const title = head?.querySelector('.email-settings-title');
if (title && accountSelectHtml) title.insertAdjacentHTML('beforeend', accountSelectHtml);
[cfg, writingStyle] = await Promise.all([
_fetchEmailSettingsConfig(),
_fetchEmailWritingStyle().catch(() => ''),
]);
} catch (err) {
page.querySelector('.email-settings-body').innerHTML = `<div class="email-settings-error">Could not load settings.</div>`;
return;
}
page.querySelector('.email-settings-body').innerHTML = _emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml(cfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(cfg);
page.querySelector('.email-settings-body')?.scrollTo(0, 0);
state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(cfg);
state._libAutoReplyDraftActive = null;
_syncEmailAutoReplyTitle(state._libAutoReplyActive);
_renderAccountsStrip();
_syncAutoReplyCalendarEvent(cfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err));
page.querySelector('#email-settings-account-select')?.addEventListener('change', async (ev) => {
state._libAccountId = ev.currentTarget.value || null;
state._libAutoReplyDraftActive = null;
_publishActiveAccount();
_syncEmailSettingsAccountPicker(page);
_renderAccountsStrip();
_resetEmailListForFreshLoad();
_loadEmails({ useCache: true });
_loadFolders({ resetMissing: true }).catch(() => {});
page.querySelector('.email-unsubscribe-inline-panel')?.remove();
const body = page.querySelector('.email-settings-body');
if (body) body.innerHTML = _emailSettingsLoadingHtml();
try {
const [nextCfg, nextStyle] = await Promise.all([
_fetchEmailSettingsConfig(),
_fetchEmailWritingStyle().catch(() => ''),
]);
if (body) body.innerHTML = _emailWritingStyleHtml(nextStyle) + _emailDisplaySettingsHtml(nextCfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(nextCfg);
state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(nextCfg);
state._libAutoReplyDraftActive = null;
_syncEmailAutoReplyTitle(state._libAutoReplyActive);
_renderAccountsStrip();
_syncAutoReplyCalendarEvent(nextCfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err));
_bindEmailSettingsPageControls(page);
} catch (_) {
if (body) body.innerHTML = `<div class="email-settings-error">Could not load settings.</div>`;
}
});
page.querySelector('.email-settings-default')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
const acctId = ev.currentTarget.dataset.emailDefaultId;
const account = (state._libAccounts || []).find(a => a && a.id === acctId);
if (!acctId || account?.is_default) return;
try {
const response = await fetch(`${API_BASE}/api/email/accounts/${encodeURIComponent(acctId)}/set-default`, {
method: 'POST', credentials: 'same-origin',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
for (const a of state._libAccounts) a.is_default = a.id === acctId;
_syncEmailSettingsAccountPicker(page);
_renderAccountsStrip();
} catch (err) {
console.error('Set default account failed:', err);
}
});
page.querySelector('.email-settings-default')?.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
ev.currentTarget.click();
});
_bindEmailSettingsPageControls(page);
}
export function _bindEmailSettingsPageControls(page) {
page.querySelector('.email-settings-clean-btn')?.addEventListener('click', (ev) => {
// Clean is an explicit fresh scan action; do not reopen stale cached
// candidates when the user asks to clean again.
_openUnsubscribeReviewModal(ev.currentTarget, { forceRescan: true });
});
const showTagsToggle = page.querySelector('#email-settings-show-tags');
const syncDisplayTagsState = () => {
const section = page.querySelector('.email-settings-display-section');
const stateLabel = page.querySelector('.email-settings-display-enabled-state');
const enabled = !!showTagsToggle?.checked;
section?.classList.toggle('is-disabled', !enabled);
section?.classList.toggle('is-enabled', enabled);
if (stateLabel) stateLabel.textContent = enabled ? 'Show' : 'Hide';
};
showTagsToggle?.addEventListener('change', (ev) => {
state._libShowTags = !!ev.target.checked;
localStorage.setItem('odysseus.email.showTags', state._libShowTags ? '1' : '0');
_renderGrid();
document.dispatchEvent(new CustomEvent('odysseus:email-tags-toggle', { detail: { show: state._libShowTags } }));
if (state._libShowTags && !_loadedEmailsHaveVisibleTags()) _notifyNoLoadedEmailTags();
syncDisplayTagsState();
});
syncDisplayTagsState();
const inlineImagesToggle = page.querySelector('#email-settings-view-inline-images');
const syncInlineImagesState = () => {
const section = page.querySelector('.email-settings-inline-images-section');
const stateLabel = page.querySelector('.email-settings-inline-images-enabled-state');
const hidden = !!inlineImagesToggle?.checked;
section?.classList.toggle('is-disabled', !hidden);
section?.classList.toggle('is-enabled', hidden);
if (stateLabel) stateLabel.textContent = hidden ? 'Hide' : 'Show';
};
inlineImagesToggle?.addEventListener('change', async (ev) => {
const hidden = !!ev.target.checked;
const enabled = !hidden;
const previous = state._libViewInlineImages;
state._libViewInlineImages = enabled;
_writeEmailInlineImagesPreference(enabled);
syncInlineImagesState();
try {
const res = await fetch(emailApiUrl('/api/email/config', {
account_id: state._libAccountId || undefined,
}), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email_view_inline_images: enabled }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
document.querySelectorAll('.email-card-reader').forEach(reader => _wireEmailInlineImages(reader));
} catch (err) {
state._libViewInlineImages = previous;
_writeEmailInlineImagesPreference(previous);
ev.target.checked = previous === false;
syncInlineImagesState();
showToast?.('Failed to save inline image setting');
console.error('Failed to save inline image setting:', err);
}
});
syncInlineImagesState();
page.querySelector('[data-open-email-tasks]')?.addEventListener('click', _openTasksForEmailTags);
const autoReplyStart = page.querySelector('#email-auto-reply-start');
const seedAutoReplyStartDate = () => {
if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();
};
// Empty native date inputs can open on an arbitrary browser baseline date.
// Seed only the draft field when the picker is opened, leaving saved settings
// unchanged until the user explicitly presses Save.
autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);
autoReplyStart?.addEventListener('focus', seedAutoReplyStartDate);
const enabledToggle = page.querySelector('#email-auto-reply-enabled');
const syncEnabledState = () => {
const section = page.querySelector('.email-settings-auto-reply-section');
const stateLabel = section?.querySelector('.email-settings-enabled-state');
const enabled = !!enabledToggle?.checked;
section?.classList.toggle('is-disabled', !enabled);
section?.classList.toggle('is-enabled', enabled);
if (stateLabel) stateLabel.textContent = enabled ? 'Enabled' : 'Disabled';
};
const saveAutoReplySettings = async ({ quiet = false } = {}) => {
const saveBtn = page.querySelector('.email-settings-save');
const status = page.querySelector('.email-settings-status');
if (saveBtn && !quiet) saveBtn.disabled = true;
_setEmailSaveIcon(saveBtn, false);
if (status) {
status.classList.remove('is-success', 'is-error');
status.textContent = 'Saving…';
}
const payload = {
email_auto_reply: !!page.querySelector('#email-auto-reply-enabled')?.checked,
email_auto_reply_start: page.querySelector('#email-auto-reply-start')?.value || '',
email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '',
email_auto_reply_subject: page.querySelector('#email-auto-reply-subject')?.value || '',
email_auto_reply_message: page.querySelector('#email-auto-reply-message')?.value || '',
email_auto_reply_cooldown: page.querySelector('#email-auto-reply-cooldown')?.value || 'period',
email_auto_reply_scope: 'account',
email_auto_reply_account_id: state._libAccountId || '',
email_auto_reply_pause_notifications: !!page.querySelector('#email-auto-reply-pause')?.checked,
};
try {
const res = await fetch(emailApiUrl('/api/email/config', {
account_id: state._libAccountId || undefined,
}), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const savedCfg = {
enabled: !!payload.email_auto_reply,
start: payload.email_auto_reply_start,
end: payload.email_auto_reply_end,
};
let calendarSynced = true;
try {
await _syncAutoReplyCalendarEvent(savedCfg);
} catch (calendarErr) {
calendarSynced = false;
console.warn('Failed to sync auto-reply calendar event:', calendarErr);
}
if (status) {
status.classList.add('is-success');
status.textContent = 'Saved';
}
_setEmailSaveIcon(saveBtn, true);
if (!quiet) showToast?.('Email settings saved');
if (!calendarSynced) showToast?.('Saved, but the calendar event could not be updated');
state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(savedCfg);
state._libAutoReplyDraftActive = null;
_syncEmailAutoReplyTitle(state._libAutoReplyActive);
_renderAccountsStrip();
_refreshUnreadBadge({ preserveAutoReplyTitle: true }).catch(() => {});
setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, quiet ? 900 : 1400);
return true;
} catch (err) {
if (status) {
status.classList.add('is-error');
status.textContent = 'Save failed';
}
showToast?.('Failed to save email settings');
return false;
} finally {
if (saveBtn) saveBtn.disabled = false;
}
};
enabledToggle?.addEventListener('change', () => {
syncEnabledState();
// Invalidate any config read that started before this toggle. Its stale
// result must not overwrite the title we are about to render.
state._autoReplyRefreshSeq += 1;
const active = _isAutoReplyActiveForCurrentAccount({
email_auto_reply: !!enabledToggle.checked,
email_auto_reply_start: autoReplyStart?.value || '',
email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '',
email_auto_reply_scope: 'account',
email_auto_reply_account_id: state._libAccountId || '',
});
state._libAutoReplyActive = active;
state._libAutoReplyDraftActive = active;
_syncEmailAutoReplyTitle(active);
_renderAccountsStrip();
saveAutoReplySettings({ quiet: true });
});
syncEnabledState();
page.querySelector('.email-settings-save')?.addEventListener('click', async () => {
await saveAutoReplySettings();
});
page.querySelector('.email-style-settings-save')?.addEventListener('click', async () => {
const saveBtn = page.querySelector('.email-style-settings-save');
const status = page.querySelector('.email-style-settings-status');
saveBtn.disabled = true;
_setEmailSaveIcon(saveBtn, false);
if (status) {
status.classList.remove('is-success', 'is-error');
status.textContent = 'Saving…';
}
try {
const res = await fetch(emailApiUrl('/api/email/style', {
account_id: state._libAccountId || undefined,
}), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ style: page.querySelector('#email-writing-style-text')?.value || '' }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (status) {
status.classList.add('is-success');
status.textContent = 'Saved';
}
_setEmailSaveIcon(saveBtn, true);
showToast?.('Email writing style saved');
setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, 1400);
} catch (err) {
if (status) {
status.classList.add('is-error');
status.textContent = 'Save failed';
}
showToast?.('Failed to save email writing style');
} finally {
saveBtn.disabled = false;
}
});
page.querySelector('.email-style-settings-extract')?.addEventListener('click', async () => {
const extractBtn = page.querySelector('.email-style-settings-extract');
const saveBtn = page.querySelector('.email-style-settings-save');
const status = page.querySelector('.email-style-settings-status');
const styleEl = page.querySelector('#email-writing-style-text');
extractBtn.disabled = true;
if (saveBtn) saveBtn.disabled = true;
let wp = null;
if (status) {
status.innerHTML = '';
try {
wp = spinnerModule.createWhirlpool(14);
wp.element.style.cssText = 'display:inline-block;vertical-align:-3px;margin-right:6px;';
status.appendChild(wp.element);
status.appendChild(document.createTextNode('Analyzing sent emails…'));
} catch (_) {
status.textContent = 'Analyzing sent emails…';
}
}
try {
const res = await fetch(emailApiUrl('/api/email/extract-style', {
account_id: state._libAccountId || undefined,
}), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sample_count: 15 }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.success || !data.style) throw new Error(data.error || `HTTP ${res.status}`);
if (styleEl) styleEl.value = data.style;
if (status) status.textContent = 'Extracted';
showToast?.('Email writing style extracted');
setTimeout(() => { if (status) status.textContent = ''; }, 1800);
} catch (err) {
if (status) status.textContent = err?.message || 'Extract failed';
showToast?.('Failed to extract email writing style');
} finally {
if (wp && wp.destroy) { try { wp.destroy(); } catch (_) {} }
extractBtn.disabled = false;
if (saveBtn) saveBtn.disabled = false;
}
});
}
export function _hideEmailSettingsPage() {
const modal = document.getElementById('email-lib-modal');
const page = document.getElementById('email-lib-settings-page');
const btn = document.getElementById('email-lib-settings-btn');
const headerBackBtn = document.getElementById('email-settings-header-back');
modal?.classList.remove('email-settings-mode');
if (page) page.hidden = true;
if (headerBackBtn) headerBackBtn.style.display = 'none';
btn?.classList.remove('active');
btn?.setAttribute('aria-expanded', 'false');
}
+4
View File
@@ -36,4 +36,8 @@ export const state = {
_libEscHandler: null,
_selectMode: false,
_selectedUids: new Set(),
// Generation guard for the away/auto-reply refresh. Written by the
// settings page and by the unread-badge refresh, which live in
// different modules, so it cannot be a module-level `let`.
_autoReplyRefreshSeq: 0,
};
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -38,7 +38,7 @@ export default function createResearchSynapse(container, opts = {}) {
<span class="rs-sep">·</span>
<span class="rs-timer">00:00</span>
</div>
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Live research map">
<g class="rs-guide" aria-hidden="true">
<ellipse cx="${cx}" cy="${cy}" rx="116" ry="72"></ellipse>
<ellipse cx="${cx}" cy="${cy}" rx="58" ry="36"></ellipse>
+40 -587
View File
@@ -4,26 +4,24 @@
import uiModule from './ui.js?v=20260916largetoolscroll1';
import searchModule from './search.js';
import { byId } from './settings/dom.js';
import {
getSettingsRegistryIssues,
isAdminManagedSettingsTab,
} from './settings/registry.js';
import { postSettings as _postSettings } from './settings/api.js';
import { initTtsSettings, initSttSettings } from './settings/speech.js';
import { initDocumentWritingStyle } from './settings/writingStyle.js';
import { initImageSettings } from './settings/imageModels.js';
import { initAgentSettings } from './settings/agent.js';
import { getSettingsRegistryIssues } from './settings/registry.js';
import { bindSettingsSearch } from './settings/search.js';
import { bindSettingsSidebar } from './settings/sidebar.js';
import {
activateSettingsPanel,
getActiveSettingsTab,
bindSettingsNavigation,
} from './settings/navigation.js';
import { bindSettingsNavigation } from './settings/navigation.js';
import {
bindSettingsDrag,
bindSettingsClose,
bindOpenPromptModalLink,
showSettingsModal,
hideSettingsModal,
} from './settings/lifecycle.js';
import { bindSettingsPeekToggle } from './settings/peek.js';
import { createSettingsShell } from './settings/shell.js';
import { handleSettingsOauthReturn } from './settings/oauthReturn.js';
import { sortModelIds } from './modelSort.js';
import { modelCaps } from './editor/ai-models.js';
import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
import { bindMenuDismiss } from './escMenuStack.js';
@@ -46,27 +44,6 @@ let _authPolicy = { password_min_length: 8 };
* Reads in this file deliberately stay direct fetches: this panel is the writer
* and edits what it reads, so it must see the authoritative state, not a cache.
*/
async function _postSettings(body) {
try {
const response = await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const error = new Error(response.status === 403
? 'Admin access is required to change these settings.'
: 'Failed to save');
error.status = response.status;
if (response.status === 403) uiModule.showError(error.message);
throw error;
}
return response;
} finally {
invalidateSettings();
}
}
const el = byId;
function esc(s) { return uiModule.esc(s); }
@@ -75,79 +52,18 @@ function safeRasterDataUrl(raw) {
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
}
/* ── Settings shell coordination ── */
function onSettingsPanelActivated(tab) {
// Appearance keeps its existing transparent preview behavior.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
// AI endpoints are intentionally refreshed only when entering the AI panel.
if (tab === 'ai') refreshAiModelEndpoints();
}
function openAdminSettingsTab(tab) {
if (window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open(tab);
return true;
}
return false;
}
/* ── Appearance-tab opacity slider ──
Mirrors the Theme customizer's slider: fades the settings modal's
background (and inner cards) via color-mix so the user can watch the
rest of the UI react to toggles, while keeping text/controls crisp
(no element opacity). Only shown/active on the Appearance tab. */
const _SETTINGS_PEEK = 55; // % opacity when the Peek toggle is on
function _applySettingsOpacity(on) {
const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content');
if (!content) return;
const cards = content.querySelectorAll('.admin-card');
if (on) {
const bgMix = `color-mix(in srgb, var(--bg) ${_SETTINGS_PEEK}%, transparent)`;
const panelMix = `color-mix(in srgb, var(--panel) ${_SETTINGS_PEEK}%, transparent)`;
content.style.setProperty('background', bgMix, 'important');
content.style.setProperty('backdrop-filter', 'none', 'important');
content.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
cards.forEach(c => {
c.style.setProperty('background', panelMix, 'important');
c.style.setProperty('backdrop-filter', 'none', 'important');
c.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
});
} else {
content.style.removeProperty('background');
content.style.removeProperty('backdrop-filter');
content.style.removeProperty('-webkit-backdrop-filter');
cards.forEach(c => {
c.style.removeProperty('background');
c.style.removeProperty('backdrop-filter');
c.style.removeProperty('-webkit-backdrop-filter');
});
}
}
// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade.
function syncAppearanceOpacity(active) {
const toggle = el('settings-opacity-wrap');
if (toggle) toggle.classList.toggle('hidden', !active);
if (active) {
_applySettingsOpacity(toggle ? toggle.classList.contains('active') : false);
} else {
_applySettingsOpacity(false); // clear the fade off the Appearance tab
}
}
function initOpacityToggle() {
const toggle = el('settings-opacity-wrap');
if (!toggle || toggle.dataset.bound === '1') return;
toggle.dataset.bound = '1';
toggle.addEventListener('click', () => {
const on = !toggle.classList.contains('active');
toggle.classList.toggle('active', on);
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
_applySettingsOpacity(on);
});
}
/* ── Settings shell coordination ──
Panel activation side effects, the admin handoff, admin-only visibility and
the public open/close API live in ./settings/shell.js. This coordinator only
supplies the panel-owned pieces the shell needs. */
const _shell = createSettingsShell({
getModal: () => modalEl,
ensureInitialized: () => { if (!initialized) initAll(); },
syncAppearanceCheckboxes: () => syncAppearanceCheckboxes(),
refreshAiModelEndpoints: () => refreshAiModelEndpoints(),
isAdmin: () => !!window._isAdmin,
getAdminModule: () => window.adminModule,
});
/* ═══════════════════════════════════════════
AI TAB
@@ -612,56 +528,6 @@ async function initTeacherModel() {
}
/* ── Image Generation ── */
async function initImageSettings() {
const modelSel = el('set-imgModelSelect');
const qualSel = el('set-imgQualitySelect');
const msg = el('set-imgSettingsMsg');
const enabledToggle = el('set-imgEnabledToggle');
const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
try {
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
const endpoints = await endpointsRes.json();
const imageModels = new Set();
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
if (!endpoint.is_enabled) return;
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId));
});
});
sortModelIds(Array.from(imageModels)).forEach(mid => {
const opt = document.createElement('option');
opt.value = mid;
opt.textContent = mid;
modelSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load models for image settings', e); }
try {
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await settingsRes.json();
if (settings.image_model) modelSel.value = settings.image_model;
if (settings.image_quality) qualSel.value = settings.image_quality;
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
} catch (e) { console.warn('Failed to load settings', e); }
function syncImgDisabled() {
var off = enabledToggle && !enabledToggle.checked;
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
}
syncImgDisabled();
async function saveSettings() {
try {
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
}
modelSel.addEventListener('change', saveSettings);
qualSel.addEventListener('change', saveSettings);
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
}
/* ── Vision ── */
async function initVisionSettings() {
@@ -743,246 +609,8 @@ async function initVisionSettings() {
/* ── Face Recognition ── */
/* ── Text to Speech ── */
async function initTtsSettings() {
var provSel = el('set-ttsProviderSelect');
var modelSelect = el('set-ttsModelSelect');
var modelInput = el('set-ttsModelInput');
var voiceSelect = el('set-ttsVoiceSelect');
var voiceInput = el('set-ttsVoiceInput');
var modelRow = el('set-ttsModelRow');
var voiceRow = el('set-ttsVoiceRow');
var speedSelect = el('set-ttsSpeedSelect');
var speedRow = el('set-ttsSpeedRow');
var ttsMsg = el('set-ttsSettingsMsg');
var ttsEnabledToggle = el('set-ttsEnabledToggle');
var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
function updateVisibility() {
var prov = provSel.value;
modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
if (isEndpoint()) {
modelSelect.style.display = ''; modelInput.style.display = 'none';
voiceSelect.style.display = ''; voiceInput.style.display = 'none';
} else {
modelSelect.style.display = 'none'; modelInput.style.display = '';
voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
}
}
var ttsKeywords = ['tts', 'audio'];
try {
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
var endpoints = await epRes.json();
endpoints.forEach(function(ep) {
if (!ep.is_enabled) return;
var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
if (!hasTTS) return;
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load endpoints for TTS', e); }
try {
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await settingsRes.json();
if (settings.tts_provider) provSel.value = settings.tts_provider;
if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
} catch (e) { console.warn('Failed to load TTS settings', e); }
function syncTtsDisabled() {
var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
}
syncTtsDisabled();
updateVisibility();
async function saveTTS() {
try {
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
} catch (e) { ttsMsg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
}
async function saveAndClearCache() {
await saveTTS();
fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
}
provSel.addEventListener('change', function() {
var prov = provSel.value;
if (prov === 'local') voiceInput.value = 'af_heart';
else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
updateVisibility();
saveTTS();
});
modelSelect.addEventListener('change', saveAndClearCache);
modelInput.addEventListener('change', saveTTS);
voiceSelect.addEventListener('change', saveAndClearCache);
voiceInput.addEventListener('change', saveTTS);
speedSelect.addEventListener('change', saveAndClearCache);
if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
// Preview / test button
var previewBtn = el('set-ttsPreviewBtn');
if (previewBtn) {
var previewAudio = null;
var previewPlaying = false;
function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
previewBtn.addEventListener('click', async function() {
if (previewPlaying) {
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
window.speechSynthesis.cancel();
resetPreview(); return;
}
var prov = provSel.value;
if (prov === 'disabled') {
ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
}
var testText = 'Hello, this is a test of text to speech.';
previewPlaying = true; previewBtn.textContent = 'Loading...';
try {
if (prov === 'browser') {
if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
var utt = new SpeechSynthesisUtterance(testText);
var voiceVal = getVoice();
if (voiceVal) {
var voices = window.speechSynthesis.getVoices();
var target = voiceVal.toLowerCase();
var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
voices.find(function(v) { return v.name.toLowerCase().includes(target); });
if (match) utt.voice = match;
}
utt.rate = parseFloat(speedSelect.value) || 1;
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
await new Promise(function(resolve, reject) {
utt.onend = resolve;
utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
window.speechSynthesis.speak(utt);
});
} else {
var res = await fetch('/api/tts/synthesize', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText, format: 'audio' })
});
if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
var blob = await res.blob();
var url = URL.createObjectURL(blob);
previewAudio = new Audio(url);
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
await new Promise(function(resolve, reject) {
previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
previewAudio.play().catch(reject);
});
}
} catch (e) {
ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
} finally {
resetPreview();
}
});
}
}
/* ── Speech to Text ── */
async function initSttSettings() {
var provSel = el('set-sttProviderSelect');
var modelSelect = el('set-sttModelSelect');
var modelInput = el('set-sttModelInput');
var modelRow = el('set-sttModelRow');
var langRow = el('set-sttLangRow');
var langInput = el('set-sttLangInput');
var sttMsg = el('set-sttSettingsMsg');
var sttEnabledToggle = el('set-sttEnabledToggle');
var sttConfigWrap = el('set-sttConfigWrap');
// STT was removed from AI Defaults — bail if the UI isn't present.
if (!provSel) return;
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
function updateVisibility() {
var prov = provSel.value;
var showModel = prov === 'local' || prov.startsWith('endpoint:');
var showLang = prov !== 'disabled';
modelRow.style.display = showModel ? 'flex' : 'none';
langRow.style.display = showLang ? 'flex' : 'none';
if (isEndpoint()) {
modelSelect.style.display = 'none'; modelInput.style.display = '';
} else {
modelSelect.style.display = ''; modelInput.style.display = 'none';
}
}
function syncSttDisabled() {
var off = sttEnabledToggle && !sttEnabledToggle.checked;
var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
}
// Effective provider: if toggle is off, treat as disabled regardless of provider select
function effectiveProvider() {
if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
return provSel.value;
}
// Add API endpoints that might support STT
try {
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
var endpoints = await epRes.json();
endpoints.forEach(function(ep) {
if (!ep.is_enabled) return;
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load endpoints for STT', e); }
// Load saved settings
try {
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await settingsRes.json();
if (settings.stt_provider) provSel.value = settings.stt_provider;
if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
if (settings.stt_language) langInput.value = settings.stt_language;
if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
} catch (e) { console.warn('Failed to load STT settings', e); }
syncSttDisabled();
updateVisibility();
async function saveSTT() {
try {
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
// Notify voiceRecorder of effective provider and update send button icon
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
} catch (e) { sttMsg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
}
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
modelSelect.addEventListener('change', saveSTT);
modelInput.addEventListener('change', saveSTT);
langInput.addEventListener('change', saveSTT);
if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
}
/* ═══════════════════════════════════════════
SEARCH TAB
@@ -1504,56 +1132,6 @@ async function initResearchSearchSettings() {
}
/* ── Agent Settings (AI tab) ── */
async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools');
var roundsInput = el('set-agentMaxRounds');
var supInput = el('set-agentSupervisorLadder');
var msg = el('set-agentMsg');
if (!toolsInput) return;
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
} catch (e) {}
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
// when blank/non-numeric. Mirrors the server-side validation.
function clampInt(raw, lo, hi, dflt) {
var n = parseInt(raw, 10);
if (isNaN(n)) return dflt;
return Math.max(lo, Math.min(n, hi));
}
async function save() {
var tools = clampInt(toolsInput.value, 0, 1000, 0);
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
toolsInput.value = tools; // reflect the clamped value
if (roundsInput) roundsInput.value = rounds;
var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
try {
await _postSettings(payload);
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
msg.style.color = 'var(--fg)';
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
}
toolsInput.addEventListener('change', save);
if (roundsInput) roundsInput.addEventListener('change', save);
if (supInput) supInput.addEventListener('change', save);
var cur = parseInt(toolsInput.value, 10) || 0;
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
(curR != null ? ' · ' + curR + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
}
/* ═══════════════════════════════════════════
APPEARANCE TAB
@@ -2151,8 +1729,8 @@ function initAll() {
modalEl = el('settings-modal');
bindSettingsNavigation(modalEl, {
openAdminTab: openAdminSettingsTab,
onPanelActivated: onSettingsPanelActivated,
openAdminTab: _shell.openAdminTab,
onPanelActivated: _shell.onPanelActivated,
});
bindSettingsSearch(modalEl, {
@@ -2182,7 +1760,7 @@ function initAll() {
closeSettings: close,
});
initOpacityToggle();
bindSettingsPeekToggle(modalEl);
initialized = true;
initDefaultChat();
initTeacherModel();
@@ -2206,69 +1784,6 @@ function initAll() {
initDocumentWritingStyle();
}
async function initDocumentWritingStyle() {
const styleEl = el('set-document-style');
const saveBtn = el('set-document-style-save');
const extractBtn = el('set-document-style-extract');
const fileEl = el('set-document-style-file');
const msg = el('set-document-style-msg');
if (!styleEl || !saveBtn) return;
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const data = await res.json();
styleEl.value = String(data.document_writing_style || '');
} catch (_) {
if (msg) msg.textContent = 'Failed to load';
}
saveBtn.addEventListener('click', async () => {
if (msg) msg.textContent = 'Saving...';
try {
const res = await _postSettings({ document_writing_style: styleEl.value });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (msg) msg.textContent = '✓ Saved';
} catch (_) {
if (msg) msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save';
}
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
});
extractBtn?.addEventListener('click', () => fileEl?.click());
fileEl?.addEventListener('change', async () => {
const file = fileEl.files?.[0];
if (!file) return;
extractBtn.disabled = true;
let whirlpool = null;
if (msg) {
msg.replaceChildren();
try {
const spinner = window.spinnerModule || (await import('./spinner.js')).default;
whirlpool = spinner.createWhirlpool(14);
whirlpool.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin-right:7px;';
const label = document.createElement('span');
label.textContent = 'Analyzing...';
msg.append(whirlpool.element, label);
} catch (_) {
msg.textContent = 'Analyzing...';
}
}
try {
const body = new FormData();
body.append('file', file);
const res = await fetch('/api/auth/settings/document-style/extract', {
method: 'POST', credentials: 'same-origin', body,
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.style) throw new Error(data.detail || data.error || 'Extraction failed');
styleEl.value = String(data.style);
if (msg) msg.textContent = '✓ Extracted — review and save';
} catch (error) {
if (msg) msg.textContent = error.message || 'Extraction failed';
} finally {
whirlpool?.destroy?.();
extractBtn.disabled = false;
fileEl.value = '';
}
});
}
function notifyIntegrationsChanged() {
try {
@@ -5112,7 +4627,11 @@ async function initUnifiedIntegrations() {
fd.append('transport', transport);
if (transport === 'stdio') {
fd.append('command', el('uf-mcp-cmd').value);
let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {}
// Unlike env below, an unparseable args value is not silently
// defaulted: it would spawn the subprocess with an empty argv.
let args;
try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); }
catch (_) { el('uf-mcp-msg').textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; return; }
let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {}
fd.append('args', args);
fd.append('env', env);
@@ -5134,7 +4653,12 @@ async function initUnifiedIntegrations() {
} else if (r.ok) {
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
} else {
el('uf-mcp-msg').textContent = `Failed (${r.status})`;
// Surface the server's reason. The Args validation above rejects
// unparseable JSON, but `"x"` and `{}` parse and are refused by
// routes/mcp/mcp_routes.py with a message naming the expected
// shape; a bare status code sends the user looking in the wrong
// place. Matches what admin.js shows for the same endpoint.
el('uf-mcp-msg').textContent = data.detail || `Failed (${r.status})`;
}
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
@@ -5645,85 +5169,14 @@ async function initUnifiedIntegrations() {
await renderList();
}
/* ── Admin visibility sync ── */
function syncAdminVisibility() {
if (!modalEl) return;
const isAdmin = !!window._isAdmin;
modalEl.querySelectorAll('.admin-only').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
}
/* ═══════════════════════════════════════════
PUBLIC API
═══════════════════════════════════════════ */
export function open(tab) {
if (!initialized) initAll();
export function open(tab) { _shell.open(tab); }
export function close() { _shell.close(); }
function syncAdminVisibility() { _shell.syncAdminVisibility(); }
syncAppearanceCheckboxes();
showSettingsModal(modalEl);
syncAdminVisibility();
if (tab) {
activateSettingsPanel(modalEl, tab);
}
// Preserve existing panel-specific side effects when Settings is opened
// directly to a tab as well as when the user navigates there.
const activeTab = tab || getActiveSettingsTab(modalEl);
onSettingsPanelActivated(activeTab);
// Auto-init admin data if showing an admin tab.
if (isAdminManagedSettingsTab(activeTab) && window.adminModule && !window.adminModule._initialized) {
window.adminModule._initData();
}
}
export function close() {
if (!modalEl) return;
// Always clear the Appearance state so the rest of the app does not remain
// dimmed if Settings is closed while that panel is active.
document.body.classList.remove('settings-appearance-open');
syncAppearanceOpacity(false);
hideSettingsModal(modalEl);
}
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
(function _handleOauthRedirect() {
const sp = new URLSearchParams(window.location.search);
if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return;
// Strip params from URL without a page reload.
const clean = window.location.pathname + window.location.hash;
window.history.replaceState(null, '', clean);
const success = sp.has('email_oauth_success');
const errMsg = sp.get('email_oauth_error') || '';
// Open settings → integrations once the document is ready. This module owns
// the open() API, so it does not need to wait for a window-level alias.
function _showResult() {
open('integrations');
// Brief toast-style banner.
const banner = document.createElement('div');
banner.textContent = success
? 'Google account connected — email is ready'
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
Object.assign(banner.style, {
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
});
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 4000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
} else {
_showResult();
}
})();
handleSettingsOauthReturn({ openSettings: open });
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
+58
View File
@@ -0,0 +1,58 @@
// Agent settings panel.
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
// functions moved, their bodies did not. settings.js imports them and calls
// them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js';
import { postSettings as _postSettings } from './api.js';
export async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools');
var roundsInput = el('set-agentMaxRounds');
var supInput = el('set-agentSupervisorLadder');
var msg = el('set-agentMsg');
if (!toolsInput) return;
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
} catch (e) {}
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
// when blank/non-numeric. Mirrors the server-side validation.
function clampInt(raw, lo, hi, dflt) {
var n = parseInt(raw, 10);
if (isNaN(n)) return dflt;
return Math.max(lo, Math.min(n, hi));
}
async function save() {
var tools = clampInt(toolsInput.value, 0, 1000, 0);
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
toolsInput.value = tools; // reflect the clamped value
if (roundsInput) roundsInput.value = rounds;
var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
try {
await _postSettings(payload);
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
msg.style.color = 'var(--fg)';
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
}
toolsInput.addEventListener('change', save);
if (roundsInput) roundsInput.addEventListener('change', save);
if (supInput) supInput.addEventListener('change', save);
var cur = parseInt(toolsInput.value, 10) || 0;
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
(curR != null ? ' · ' + curR + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
}
+26
View File
@@ -0,0 +1,26 @@
// Lifted verbatim from static/js/settings.js so extracted panels can post
// settings without importing the module they were extracted from.
import uiModule from '../ui.js?v=20260916largetoolscroll1';
import { invalidateSettings } from '../appConfig.js';
export async function postSettings(body) {
try {
const response = await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const error = new Error(response.status === 403
? 'Admin access is required to change these settings.'
: 'Failed to save');
error.status = response.status;
if (response.status === 403) uiModule.showError(error.message);
throw error;
}
return response;
} finally {
invalidateSettings();
}
}
+60
View File
@@ -0,0 +1,60 @@
// Image-model settings panel.
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
// functions moved, their bodies did not. settings.js imports them and calls
// them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js';
import { postSettings as _postSettings } from './api.js';
import { modelCaps } from '../editor/ai-models.js';
import { sortModelIds } from '../modelSort.js';
export async function initImageSettings() {
const modelSel = el('set-imgModelSelect');
const qualSel = el('set-imgQualitySelect');
const msg = el('set-imgSettingsMsg');
const enabledToggle = el('set-imgEnabledToggle');
const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
try {
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
const endpoints = await endpointsRes.json();
const imageModels = new Set();
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
if (!endpoint.is_enabled || !endpoint.online) return;
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId));
});
});
sortModelIds(Array.from(imageModels)).forEach(mid => {
const opt = document.createElement('option');
opt.value = mid;
opt.textContent = mid;
modelSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load models for image settings', e); }
try {
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await settingsRes.json();
if (settings.image_model) modelSel.value = settings.image_model;
if (settings.image_quality) qualSel.value = settings.image_quality;
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
} catch (e) { console.warn('Failed to load settings', e); }
function syncImgDisabled() {
var off = enabledToggle && !enabledToggle.checked;
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
}
syncImgDisabled();
async function saveSettings() {
try {
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
}
modelSel.addEventListener('change', saveSettings);
qualSel.addEventListener('change', saveSettings);
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
}
+47
View File
@@ -0,0 +1,47 @@
// Return path from the Google OAuth2 redirect.
//
// The provider sends the browser back to the app root with a result in the
// query string, so something has to notice on load and put the user back where
// they were — the Integrations panel — with the outcome visible. That is
// navigation, not an Integrations concern, and it runs once per page load
// rather than once per panel open.
/**
* @param {object} options
* @param {(tab: string) => void} options.openSettings the Settings open() entry point
*/
export function handleSettingsOauthReturn({ openSettings } = {}) {
const sp = new URLSearchParams(window.location.search);
if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return;
// Strip params from URL without a page reload.
const clean = window.location.pathname + window.location.hash;
window.history.replaceState(null, '', clean);
const success = sp.has('email_oauth_success');
const errMsg = sp.get('email_oauth_error') || '';
// The caller passes its own open() in, so this never has to wait for a
// window-level alias to exist.
function _showResult() {
openSettings('integrations');
// Brief toast-style banner.
const banner = document.createElement('div');
banner.textContent = success
? 'Google account connected — email is ready'
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
Object.assign(banner.style, {
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
});
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 4000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
} else {
_showResult();
}
}
+68
View File
@@ -0,0 +1,68 @@
// Settings window peek (transparency) chrome.
//
// The Appearance panel lets the user watch the rest of the UI react to a toggle
// without closing Settings. That is window chrome, not panel data, so it lives
// here rather than with the Appearance panel's checkbox handling: the fade is
// applied to the Settings window's own background via color-mix — never element
// opacity, so controls and text stay crisp — and it has to be cleared the
// moment the user leaves Appearance or closes the window. Mirrors the Theme
// customizer's slider.
import { byId } from './dom.js';
const PEEK_OPACITY = 55; // % opacity when the Peek toggle is on
const TOGGLE_ID = 'settings-opacity-wrap';
function _toggleEl() {
return byId(TOGGLE_ID);
}
export function applySettingsPeek(modalEl, on) {
const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content');
if (!content) return;
const cards = content.querySelectorAll('.admin-card');
if (on) {
const bgMix = `color-mix(in srgb, var(--bg) ${PEEK_OPACITY}%, transparent)`;
const panelMix = `color-mix(in srgb, var(--panel) ${PEEK_OPACITY}%, transparent)`;
content.style.setProperty('background', bgMix, 'important');
content.style.setProperty('backdrop-filter', 'none', 'important');
content.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
cards.forEach(c => {
c.style.setProperty('background', panelMix, 'important');
c.style.setProperty('backdrop-filter', 'none', 'important');
c.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
});
} else {
content.style.removeProperty('background');
content.style.removeProperty('backdrop-filter');
content.style.removeProperty('-webkit-backdrop-filter');
cards.forEach(c => {
c.style.removeProperty('background');
c.style.removeProperty('backdrop-filter');
c.style.removeProperty('-webkit-backdrop-filter');
});
}
}
// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade.
export function syncSettingsPeek(modalEl, active) {
const toggle = _toggleEl();
if (toggle) toggle.classList.toggle('hidden', !active);
if (active) {
applySettingsPeek(modalEl, toggle ? toggle.classList.contains('active') : false);
} else {
applySettingsPeek(modalEl, false); // clear the fade off the Appearance tab
}
}
export function bindSettingsPeekToggle(modalEl) {
const toggle = _toggleEl();
if (!toggle || toggle.dataset.bound === '1') return;
toggle.dataset.bound = '1';
toggle.addEventListener('click', () => {
const on = !toggle.classList.contains('active');
toggle.classList.toggle('active', on);
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
applySettingsPeek(modalEl, on);
});
}
+105
View File
@@ -0,0 +1,105 @@
// Settings shell coordination.
//
// settings.js still owns every panel's data loading. This module owns the shell
// around them: what happens when a panel becomes active, where an
// admin-managed tab is handed off to admin.js, which elements are admin-only,
// and the public open/close entry points.
//
// It keeps no panel state. Everything it needs from the panels arrives through
// the options below, so panels can move out of settings.js one at a time
// without this file changing — and so the shell's behavior is testable without
// booting a panel.
import { activateSettingsPanel, getActiveSettingsTab } from './navigation.js';
import { showSettingsModal, hideSettingsModal } from './lifecycle.js';
import { isAdminManagedSettingsTab } from './registry.js';
import { syncSettingsPeek } from './peek.js';
/**
* @param {object} options
* @param {() => Element|null} options.getModal the Settings modal, once initialized
* @param {() => void} options.ensureInitialized first-open panel initialization
* @param {() => void} options.syncAppearanceCheckboxes Appearance panel state refresh
* @param {() => void} options.refreshAiModelEndpoints AI panel endpoint refresh
* @param {() => boolean} options.isAdmin current admin status
* @param {() => object|null} options.getAdminModule the lazily loaded admin module
*/
export function createSettingsShell(options = {}) {
const getModal = options.getModal;
const ensureInitialized = options.ensureInitialized;
const syncAppearanceCheckboxes = options.syncAppearanceCheckboxes;
const refreshAiModelEndpoints = options.refreshAiModelEndpoints;
const isAdmin = options.isAdmin;
const getAdminModule = options.getAdminModule;
const _modal = () => (typeof getModal === 'function' ? getModal() : null);
const _admin = () => (typeof getAdminModule === 'function' ? getAdminModule() : null);
function onPanelActivated(tab) {
// Appearance keeps its existing transparent preview behavior.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncSettingsPeek(_modal(), tab === 'appearance');
// AI endpoints are intentionally refreshed only when entering the AI panel.
if (tab === 'ai' && typeof refreshAiModelEndpoints === 'function') {
refreshAiModelEndpoints();
}
}
function openAdminTab(tab) {
const admin = _admin();
if (admin && typeof admin.open === 'function') {
admin.open(tab);
return true;
}
return false;
}
function syncAdminVisibility() {
const modalEl = _modal();
if (!modalEl) return;
const admin = typeof isAdmin === 'function' ? !!isAdmin() : false;
modalEl.querySelectorAll('.admin-only').forEach(el => {
el.style.display = admin ? '' : 'none';
});
}
function open(tab) {
if (typeof ensureInitialized === 'function') ensureInitialized();
if (typeof syncAppearanceCheckboxes === 'function') syncAppearanceCheckboxes();
const modalEl = _modal();
showSettingsModal(modalEl);
syncAdminVisibility();
if (tab) {
activateSettingsPanel(modalEl, tab);
}
// Preserve existing panel-specific side effects when Settings is opened
// directly to a tab as well as when the user navigates there.
const activeTab = tab || getActiveSettingsTab(modalEl);
onPanelActivated(activeTab);
// Auto-init admin data if showing an admin tab.
const admin = _admin();
if (isAdminManagedSettingsTab(activeTab) && admin && !admin._initialized) {
admin._initData();
}
}
function close() {
const modalEl = _modal();
if (!modalEl) return;
// Always clear the Appearance state so the rest of the app does not remain
// dimmed if Settings is closed while that panel is active.
document.body.classList.remove('settings-appearance-open');
syncSettingsPeek(modalEl, false);
hideSettingsModal(modalEl);
}
return { open, close, onPanelActivated, openAdminTab, syncAdminVisibility };
}
+78
View File
@@ -121,10 +121,88 @@ export function setSettingsSidebarWidth(modalEl, width, options = {}) {
return next;
}
/* ── Build provenance ──
The bottom-left of the panel answers "which build am I actually looking
at": the version this build registers and the commit its process loaded.
Both come from /api/version, which resolves the commit once at import via
`git rev-parse HEAD` and reports the string "unknown" when it cannot — a
read-only Docker tree with no .git, for instance. */
const BUILD_COMMIT_SHORT_LEN = 8;
const BUILD_UNKNOWN = 'unknown';
const BUILD_COMMIT_SHA = /^[0-9a-f]{7,64}$/i;
function buildField(value) {
const text = String(value ?? '').trim();
return text && text.toLowerCase() !== BUILD_UNKNOWN ? text : '';
}
/** Turn an /api/version payload into the two sidebar lines, or null when it
* carries nothing worth a footer. */
export function formatSettingsBuildInfo(payload) {
const version = buildField(payload?.version);
const build = buildField(payload?.build);
const commit = buildField(payload?.source_commit);
const parts = [];
if (version) parts.push(`v${version}`);
// The preview harness versions itself separately from the public semver, so
// show both — but never the same number twice.
if (build && build !== version) parts.push(`build ${build}`);
// An ODYSSEUS_SOURCE_COMMIT override need not be a sha; leave those intact
// and let the CSS ellipsis deal with the width.
const commitLabel = BUILD_COMMIT_SHA.test(commit)
? commit.slice(0, BUILD_COMMIT_SHORT_LEN).toLowerCase()
: commit;
if (!parts.length && !commitLabel) return null;
return {
versionLabel: parts.join(' \u00b7 '),
commitLabel,
commitTitle: commit,
};
}
function paintSettingsBuildInfo(modalEl, info) {
const host = modalEl?.querySelector('#settings-sidebar-build');
if (!host) return;
const versionEl = host.querySelector('#settings-sidebar-build-version');
const commitEl = host.querySelector('#settings-sidebar-build-commit');
if (!info) {
host.hidden = true;
return;
}
if (versionEl) versionEl.textContent = info.versionLabel;
if (commitEl) {
commitEl.textContent = info.commitLabel;
// The short hash is what fits; keep the full one reachable on hover.
if (info.commitTitle) commitEl.title = info.commitTitle;
}
host.hidden = false;
}
async function loadSettingsBuildInfo(modalEl) {
try {
const res = await fetch('/api/version', { credentials: 'same-origin' });
if (!res.ok) return;
paintSettingsBuildInfo(modalEl, formatSettingsBuildInfo(await res.json()));
} catch {
// Provenance is informational: a failed probe leaves the footer hidden
// rather than putting an error in the middle of the settings nav.
}
}
export function bindSettingsSidebar(modalEl) {
if (!modalEl || _bound.has(modalEl)) return;
_bound.add(modalEl);
loadSettingsBuildInfo(modalEl);
const sidebar = modalEl.querySelector('.settings-sidebar');
const handle = modalEl.querySelector('#settings-sidebar-resize-handle');
const toggle = modalEl.querySelector('#settings-sidebar-toggle');
+247
View File
@@ -0,0 +1,247 @@
// Text-to-speech and speech-to-text settings panels.
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
// functions moved, their bodies did not. settings.js imports them and calls
// them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js';
import { postSettings as _postSettings } from './api.js';
export async function initTtsSettings() {
var provSel = el('set-ttsProviderSelect');
var modelSelect = el('set-ttsModelSelect');
var modelInput = el('set-ttsModelInput');
var voiceSelect = el('set-ttsVoiceSelect');
var voiceInput = el('set-ttsVoiceInput');
var modelRow = el('set-ttsModelRow');
var voiceRow = el('set-ttsVoiceRow');
var speedSelect = el('set-ttsSpeedSelect');
var speedRow = el('set-ttsSpeedRow');
var ttsMsg = el('set-ttsSettingsMsg');
var ttsEnabledToggle = el('set-ttsEnabledToggle');
var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
function updateVisibility() {
var prov = provSel.value;
modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
if (isEndpoint()) {
modelSelect.style.display = ''; modelInput.style.display = 'none';
voiceSelect.style.display = ''; voiceInput.style.display = 'none';
} else {
modelSelect.style.display = 'none'; modelInput.style.display = '';
voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
}
}
var ttsKeywords = ['tts', 'audio'];
try {
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
var endpoints = await epRes.json();
endpoints.forEach(function(ep) {
if (!ep.is_enabled) return;
var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
if (!hasTTS) return;
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load endpoints for TTS', e); }
try {
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await settingsRes.json();
if (settings.tts_provider) provSel.value = settings.tts_provider;
if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
} catch (e) { console.warn('Failed to load TTS settings', e); }
function syncTtsDisabled() {
var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
}
syncTtsDisabled();
updateVisibility();
async function saveTTS() {
try {
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
} catch (e) { ttsMsg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
}
async function saveAndClearCache() {
await saveTTS();
fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
}
provSel.addEventListener('change', function() {
var prov = provSel.value;
if (prov === 'local') voiceInput.value = 'af_heart';
else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
updateVisibility();
saveTTS();
});
modelSelect.addEventListener('change', saveAndClearCache);
modelInput.addEventListener('change', saveTTS);
voiceSelect.addEventListener('change', saveAndClearCache);
voiceInput.addEventListener('change', saveTTS);
speedSelect.addEventListener('change', saveAndClearCache);
if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
// Preview / test button
var previewBtn = el('set-ttsPreviewBtn');
if (previewBtn) {
var previewAudio = null;
var previewPlaying = false;
function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
previewBtn.addEventListener('click', async function() {
if (previewPlaying) {
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
window.speechSynthesis.cancel();
resetPreview(); return;
}
var prov = provSel.value;
if (prov === 'disabled') {
ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
}
var testText = 'Hello, this is a test of text to speech.';
previewPlaying = true; previewBtn.textContent = 'Loading...';
try {
if (prov === 'browser') {
if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
var utt = new SpeechSynthesisUtterance(testText);
var voiceVal = getVoice();
if (voiceVal) {
var voices = window.speechSynthesis.getVoices();
var target = voiceVal.toLowerCase();
var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
voices.find(function(v) { return v.name.toLowerCase().includes(target); });
if (match) utt.voice = match;
}
utt.rate = parseFloat(speedSelect.value) || 1;
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
await new Promise(function(resolve, reject) {
utt.onend = resolve;
utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
window.speechSynthesis.speak(utt);
});
} else {
var res = await fetch('/api/tts/synthesize', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText, format: 'audio' })
});
if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
var blob = await res.blob();
var url = URL.createObjectURL(blob);
previewAudio = new Audio(url);
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
await new Promise(function(resolve, reject) {
previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
previewAudio.play().catch(reject);
});
}
} catch (e) {
ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
} finally {
resetPreview();
}
});
}
}
export async function initSttSettings() {
var provSel = el('set-sttProviderSelect');
var modelSelect = el('set-sttModelSelect');
var modelInput = el('set-sttModelInput');
var modelRow = el('set-sttModelRow');
var langRow = el('set-sttLangRow');
var langInput = el('set-sttLangInput');
var sttMsg = el('set-sttSettingsMsg');
var sttEnabledToggle = el('set-sttEnabledToggle');
var sttConfigWrap = el('set-sttConfigWrap');
// STT was removed from AI Defaults — bail if the UI isn't present.
if (!provSel) return;
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
function updateVisibility() {
var prov = provSel.value;
var showModel = prov === 'local' || prov.startsWith('endpoint:');
var showLang = prov !== 'disabled';
modelRow.style.display = showModel ? 'flex' : 'none';
langRow.style.display = showLang ? 'flex' : 'none';
if (isEndpoint()) {
modelSelect.style.display = 'none'; modelInput.style.display = '';
} else {
modelSelect.style.display = ''; modelInput.style.display = 'none';
}
}
function syncSttDisabled() {
var off = sttEnabledToggle && !sttEnabledToggle.checked;
var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
}
// Effective provider: if toggle is off, treat as disabled regardless of provider select
function effectiveProvider() {
if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
return provSel.value;
}
// Add API endpoints that might support STT
try {
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
var endpoints = await epRes.json();
endpoints.forEach(function(ep) {
if (!ep.is_enabled) return;
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load endpoints for STT', e); }
// Load saved settings
try {
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await settingsRes.json();
if (settings.stt_provider) provSel.value = settings.stt_provider;
if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
if (settings.stt_language) langInput.value = settings.stt_language;
if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
} catch (e) { console.warn('Failed to load STT settings', e); }
syncSttDisabled();
updateVisibility();
async function saveSTT() {
try {
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
// Notify voiceRecorder of effective provider and update send button icon
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
} catch (e) { sttMsg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
}
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
modelSelect.addEventListener('change', saveSTT);
modelInput.addEventListener('change', saveSTT);
langInput.addEventListener('change', saveSTT);
if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
}
+71
View File
@@ -0,0 +1,71 @@
// Document writing-style settings panel.
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
// functions moved, their bodies did not. settings.js imports them and calls
// them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js';
import { postSettings as _postSettings } from './api.js';
export async function initDocumentWritingStyle() {
const styleEl = el('set-document-style');
const saveBtn = el('set-document-style-save');
const extractBtn = el('set-document-style-extract');
const fileEl = el('set-document-style-file');
const msg = el('set-document-style-msg');
if (!styleEl || !saveBtn) return;
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const data = await res.json();
styleEl.value = String(data.document_writing_style || '');
} catch (_) {
if (msg) msg.textContent = 'Failed to load';
}
saveBtn.addEventListener('click', async () => {
if (msg) msg.textContent = 'Saving...';
try {
const res = await _postSettings({ document_writing_style: styleEl.value });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (msg) msg.textContent = '✓ Saved';
} catch (e) {
if (msg) msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save';
}
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
});
extractBtn?.addEventListener('click', () => fileEl?.click());
fileEl?.addEventListener('change', async () => {
const file = fileEl.files?.[0];
if (!file) return;
extractBtn.disabled = true;
let whirlpool = null;
if (msg) {
msg.replaceChildren();
try {
const spinner = window.spinnerModule || (await import('../spinner.js')).default;
whirlpool = spinner.createWhirlpool(14);
whirlpool.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin-right:7px;';
const label = document.createElement('span');
label.textContent = 'Analyzing...';
msg.append(whirlpool.element, label);
} catch (_) {
msg.textContent = 'Analyzing...';
}
}
try {
const body = new FormData();
body.append('file', file);
const res = await fetch('/api/auth/settings/document-style/extract', {
method: 'POST', credentials: 'same-origin', body,
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.style) throw new Error(data.detail || data.error || 'Extraction failed');
styleEl.value = String(data.style);
if (msg) msg.textContent = '✓ Extracted — review and save';
} catch (error) {
if (msg) msg.textContent = error.message || 'Extraction failed';
} finally {
whirlpool?.destroy?.();
extractBtn.disabled = false;
fileEl.value = '';
}
});
}
+1 -1
View File
@@ -316,7 +316,7 @@ class SmoothPad {
}
function _modal(innerHtml) {
// Match the app's standard .modal pattern (defined in static/style.css).
// Match the app's standard .modal pattern from the main stylesheet cascade.
const overlay = document.createElement('div');
overlay.className = 'modal sig-modal-overlay';
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.45);z-index:10100;';
+4 -4
View File
@@ -106,7 +106,7 @@
<style>
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
/* Mirror the main app's :root defaults from the stylesheet cascade so an
uncustomized theme — or a fresh browser with no `odysseus-theme` in
localStorage — renders the login page in the same palette as the rest
of the app instead of a different greys+offwhite scheme. */
@@ -115,7 +115,7 @@
--red: #e06c75; --green: #50fa7b;
--bg-effect-intensity: 1;
}
/* Background patterns — mirrors static/style.css "Background Patterns"
/* Background patterns — mirrors the main app "Background Patterns" styles
block so the theme's chosen bgPattern renders on login too. The
canvas-based effects (rain, constellations, perlin-flow, petals,
sparkles, embers) are spawned by the deferred theme.js import below;
@@ -157,7 +157,7 @@
font-size: 2rem; font-weight: 600;
letter-spacing: 0.04em;
/* Same gradient as the main app's welcome-screen "Odysseus" title
(static/style.css #welcome-screen .welcome-name) so the brand
(the main cascade's #welcome-screen .welcome-name) so the brand
wordmark is consistent across login + first-launch screens. */
background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg)));
-webkit-background-clip: text;
@@ -175,7 +175,7 @@
/* On touch devices keep inputs at >=16px so iOS Safari doesn't zoom the whole
page when a field is focused (it auto-zooms any focused input under 16px).
This page has its own inline styles, so it doesn't inherit the main app's
equivalent rule in static/style.css; mirror it here. !important also lifts
equivalent rule in the main stylesheet cascade; mirror it here. !important also lifts
the dynamically-inserted 2FA input, which pins font-size:14px inline. */
@media (hover: none) and (pointer: coarse) {
input:not(.remember-check) { font-size: 16px !important; }
-51566
View File
File diff suppressed because it is too large Load Diff
+40 -4
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v651-chatgpt-usage-collapse';
const CACHE_NAME = 'odysseus-v656-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -39,8 +39,28 @@ const KATEX_FONTS = [
// exact URL the browser requests, query string included.
const PRECACHE = [
'/',
'/static/style.css?v=20260921chatgptusage2',
'/static/app.js?v=20260921chatgptusage2',
'/static/css/00-tokens.css?v=20260929csssplit',
'/static/css/01-agent-chat.css?v=20260929csssplit',
'/static/css/02-compare.css?v=20260929csssplit',
'/static/css/03-agent-chat.css?v=20260929csssplit',
'/static/css/04-memory.css?v=20260929csssplit',
'/static/css/05-documents.css?v=20260929csssplit',
'/static/css/06-admin-settings.css?v=20260929csssplit',
'/static/css/07-documents.css?v=20260929csssplit',
'/static/css/08-skills.css?v=20260929csssplit',
'/static/css/09-gallery.css?v=20260929csssplit',
'/static/css/10-cookbook.css?v=20260929csssplit',
'/static/css/11-tasks.css?v=20260929csssplit',
'/static/css/12-gallery.css?v=20260929csssplit',
'/static/css/13-image-editor.css?v=20260929csssplit',
'/static/css/14-email.css?v=20260929csssplit',
'/static/css/15-notes.css?v=20260929csssplit',
'/static/css/16-calendar.css?v=20260929csssplit',
'/static/css/17-research.css?v=20260929csssplit',
'/static/css/documents-gallery-editor.css?v=20260929csssplit',
'/static/css/email-calendar-notes-tasks.css?v=20260929csssplit',
'/static/css/cookbook-research-memory-settings.css?v=20260929csssplit',
'/static/app.js?v=20260929csssplit',
'/static/js/storage.js',
'/static/js/appConfig.js',
'/static/js/ui.js?v=20260916largetoolscroll1',
@@ -75,15 +95,25 @@ const PRECACHE = [
'/static/js/theme.js?v=20260911organsrain1',
'/static/js/censor.js',
'/static/js/settings.js?v=20260912writingstyle3',
'/static/js/admin.js?v=20260921chatgptusage2',
'/static/js/admin.js?v=20260929csssplit',
'/static/js/chatgptSubscriptionUsage.js',
'/static/js/init.js?v=20260829chatstyle12',
'/static/js/slashCommands.js?v=20260921chatgptusage1',
'/static/js/research/jobs.js?v=20260910researcherrorpersist1',
'/static/js/emailInbox.js?v=20260914aireply4',
'/static/js/emailLibrary/index.js',
'/static/js/emailLibrary/aiReply.js',
'/static/js/emailLibrary/attachments.js',
'/static/js/emailLibrary/bodyRender.js',
'/static/js/emailLibrary/menus.js',
'/static/js/emailLibrary/reader.js',
'/static/js/emailLibrary/settingsPage.js',
'/static/js/emailLibrary/unsubscribe.js',
'/static/js/emailLibrary/utils.js',
'/static/js/emailLibrary/signatureFold.js',
'/static/js/emailLibrary/state.js',
// emailInbox.js has always imported this one; it was never precached.
'/static/js/emailLibrary/replyRecipients.js',
'/static/js/notes.js?v=20260911notesselectioncancel1',
'/static/js/tasks.js?v=20260914taskmodel1',
'/static/js/calendar.js?v=20260914emailsource11',
@@ -113,6 +143,7 @@ const PANEL_PRECACHE = [
'/static/js/galleryEditor.js?v=20260909movepicklayer1',
'/static/js/editor/ai-inpaint.js?v=20260708match1',
'/static/js/editor/ai-models.js',
'/static/js/editor/ai-operation.js',
'/static/js/editor/ai-rembg.js',
'/static/js/editor/ai-tool-runner.js',
'/static/js/editor/ai-tools-misc.js',
@@ -143,6 +174,8 @@ const PANEL_PRECACHE = [
'/static/js/editor/document-geometry.js',
'/static/js/editor/export-dialog.js',
'/static/js/editor/selection-mask.js',
'/static/js/editor/selection-modifiers.js',
'/static/js/editor/tool-shortcuts.js',
'/static/js/editor/filters/blur.js',
'/static/js/editor/filters/edge-feather.js',
'/static/js/editor/fx/adj-popup.js',
@@ -163,6 +196,8 @@ const PANEL_PRECACHE = [
'/static/js/editor/layer-geometry.js',
'/static/js/editor/precision-guides.js',
'/static/js/editor/layer-panel.js',
'/static/js/editor/layer-style-menu.js',
'/static/js/editor/layer-styles.js',
'/static/js/editor/mask-utils.js',
'/static/js/editor/shortcuts-popover.js',
'/static/js/editor/slider-ux.js',
@@ -183,6 +218,7 @@ const PANEL_PRECACHE = [
'/static/js/editor/tools/lasso.js',
'/static/js/editor/tools/marquee.js',
'/static/js/editor/tools/move.js',
'/static/js/editor/tools/pen-selection.js',
'/static/js/editor/tools/stroke.js',
'/static/js/editor/tools/transform-drag.js',
'/static/js/editor/tools/transform-handles.js',
+133
View File
@@ -0,0 +1,133 @@
# Known full-suite failures
`python -m pytest -q` does not come back clean on every machine, and it never
has. Without a list of which failures are expected, a first local run is
uninterpretable: you cannot tell "you broke something" from "you are on a Mac",
so the usual result is either chasing a non-bug or ignoring a real one.
This is that list. It is a record of observation, not a permission slip: a test
here is still a test that does not pass, and the three that remain below are
all still worth someone's time.
Last measured: `lab @ c499c01b` plus the fixes in this change, macOS 15 on
Apple Silicon, Python 3.11, with the **default** `$TMPDIR` — see the socket
entry below for why that qualifier is load-bearing.
```
3 failed, 10658 passed, 6 skipped
```
## Get the prerequisites right first
Most "surprise" failures are a missing dependency rather than anything in this
file. A clean run needs all of:
```bash
python3.11 -m venv venv
./venv/bin/python -m pip install -r requirements.txt
npm ci # the browser tests shell out to node
npx playwright install chromium # ~30 tests drive a real browser
mkdir -p data # SQLite lives at ./data/app.db
```
plus `ffmpeg` on `PATH` for the media tests.
If you already have a ChromaDB running, point `CHROMADB_PORT` at a closed port
for the run. The client reaches Chroma over HTTP regardless of the data
directory, so a test run will otherwise attach to whatever store is listening,
including one holding real data.
Miss `npm ci` and roughly 36 browser tests fail on `Cannot find package
'playwright'`. That is not a regression, it is the missing install.
## The three that remain, and the seven that no longer do
### Test bugs: comparing an unresolved path against a resolved one
Three failures compared an unresolved `/tmp` path against a resolved
`/private/tmp` one, and are fixed rather than listed:
- `tests/test_code_nav_tools.py` (two tests) built a fixture under
`tempfile.mkdtemp(dir="/tmp")` and compared it against the path the code
reports, which it resolves.
- `tests/test_workspace_confine.py::test_glob_confined_e2e` mixed
`os.path.realpath(ws)` with an unresolved secret directory, so `relpath`
produced `../../../../tmp/<absolute path>` and the assertion that the
absolute path was absent matched it as a substring.
Both now resolve consistently. They are recorded here because the shape recurs:
on macOS, mixing a resolved and an unresolved temp path is a test bug that
looks like a platform failure.
### Test bugs: a temp path too long to bind a socket to
Four more, same family, invisible unless `$TMPDIR` is long enough:
- `tests/test_shell_routes.py::TestHostDockerAccess` (three tests)
- `tests/test_cookbook_docker_access.py::test_container_opt_in_with_unix_socket_is_allowed`
```
OSError: AF_UNIX path too long
```
Each bound an `AF_UNIX` socket at `tmp_path / "docker.sock"`. macOS gives
`sun_path` 104 bytes including the terminator, and pytest's `tmp_path` is
rooted at `$TMPDIR`, which on a stock Mac is a 49-character
`/var/folders/<2>/<30>/T/`. Add `pytest-of-<user>/pytest-<n>/` and the test's
own name and the bind path is 115 bytes before the filename.
This is why the counts above depend on where you run from: under a shortened
`$TMPDIR` the path lands at 103 and the tests pass, and it tips over the moment
pytest's run counter reaches two digits. Linux allows 108 bytes and roots
`$TMPDIR` at `/tmp`, so it never bites there and CI stays green.
They now bind through `tests/helpers/unix_sockets.bound_unix_socket`, which
puts the socket under a short directory. **Measure with the default `$TMPDIR`**
— `env -u TMPDIR` or an explicit `/var/folders/...` — or this whole file
records a run nobody else has.
### Optional dependency: ffmpeg without a WebP encoder
- `tests/test_inspect_media_tool.py::test_inspect_media_exports_final_decodable_frame_at_exact_duration`
```
ffmpeg still extraction failed: Automatic encoder selection failed ...
Error opening output files: Encoder not found
```
The test asks ffmpeg for a `.webp` still and asserts `exit_code == 0`. WebP
encoding is a build option, and Homebrew's ffmpeg does not always carry it. CI
installs a build that does, which is why this is green there.
**Needs a decision**: skip when the encoder is absent, or fall back to PNG. The
current shape asserts success from a codec that is not guaranteed present.
### Environmental: real sockets
- `tests/test_integration_api_call_ssrf.py::test_real_socket_falls_back_from_dead_first_to_live_second`
```
httpcore.ConnectTimeout / httpx.ConnectTimeout
```
Opens real sockets and depends on a connection to a dead address being refused
quickly rather than hanging. Sandboxed and restricted-network machines time out
instead. Genuinely environmental.
### Unexplained: rich-text colour contrast
- `tests/test_document_rich_color_reset_and_contrast.py::test_rich_colors_follow_theme_and_undo_as_one_edit`
A Playwright run times out waiting for `#doc-email-richbody p` to contain a
`span` after a colour is applied.
**This one is not flaky.** Three consecutive runs failed identically, each at
about 31 seconds. It was previously written off as timing noise and that was
wrong. The cause is not established, and until it is, treat it as a possible
real defect in the rich-text colour path rather than a platform artifact.
## Keeping this current
Re-measure on a clean checkout of `lab` with the prerequisites above, and
update the header revision, the counts and any entry that changed. A failure
that appears and is not listed here is a regression until shown otherwise.
+62
View File
@@ -33,6 +33,8 @@ the sub-area. The `area_*` names are registered in `pyproject.toml`; the dynamic
`sub_*` names are registered before collection by `pytest_configure` in
`tests/conftest.py`, so unknown-mark warnings still flag genuine typos.
The full suite does not come back clean on every machine. [KNOWN_FAILURES.md](KNOWN_FAILURES.md) lists which failures are expected, which are test bugs worth fixing, and the prerequisites a clean run needs; anything not on that list is a regression until shown otherwise.
For common focused runs, use `tests/run_focus.py`. It validates area and
sub-area names, accepts sub-areas with or without the `sub_` prefix, and passes
extra pytest arguments after `--`:
@@ -137,6 +139,50 @@ The runner propagates pytest's exit code, so it composes with normal local
workflows; "report-only" means it is not a CI gate, not that failures are
swallowed.
## CSS computed-style snapshot
`tests/test_css_computed_style_snapshot.py` pins the rendered result of
the shipped ordered stylesheet cascade, whose behavior depends on source order,
by hashing `getComputedStyle` over a fixed element inventory across pages,
viewports, themes and density modes. Any PR that moves CSS has to produce an
identical digest or explain why it did not.
```bash
./venv/bin/python -m pytest tests/test_css_computed_style_snapshot.py
./venv/bin/python scripts/css_snapshot.py --check # standalone, no pytest
./venv/bin/python scripts/css_snapshot.py --write-baseline # re-record, deliberately
```
The inventory, the baseline and the capture live in `tests/css_snapshot/`;
`tests/css_snapshot/README.md` documents what is covered, what is deliberately
not, and how to find the property that moved when it fails. The run takes about
21 seconds and skips when `npm ci` has not been run.
## Release smoke suite
`tests/smoke/` drives every advertised feature area once, end to end,
against a real instance - the safety net the unit suite does not provide
for a route move or a module split. One command boots the worktree and
runs it:
```bash
scripts/odysseus-smoke # boot, run every area, stop again
scripts/odysseus-smoke --keep-up # leave the instance running
scripts/odysseus-smoke --areas # the coverage table, without booting
```
It reads its target instance out of the environment (`APP_PORT` through
`internal_api_base()`, plus the dev admin account), so under a plain
`pytest` with nothing booted every scenario skips with the reason and
the full suite stays green. Models are served by a deterministic
loopback stub, never a live endpoint; email uses the repo's existing
`ODYSSEUS_EMAIL_FIXTURE` path.
The report is a per-area table that also prints the areas the suite
deliberately does not cover, so it cannot be read as coverage of
everything it omits. `tests/smoke/README.md` documents what is in each
list and why.
## Core principles
- Keep PRs small and homogeneous: one kind of change per PR.
@@ -153,6 +199,22 @@ The helpers below live under `tests/helpers/`. They exist to remove repeated
boilerplate that already appeared across multiple tests. Reach for one only when
your test matches its intended use; do not stretch a helper to cover a new case.
### `tests.helpers.stylesheets.app_css`
Use when a test asserts on a CSS rule.
- Returns every app stylesheet concatenated in the order `static/index.html`
loads them, which is the order the cascade actually has.
- App styles live across an ordered cascade; reading one fragment alone ties
the test to whichever file a rule sits in today, so it goes red
when a rule moves without the rendered page changing.
- `stylesheet_paths()` and `stylesheet_urls()` are there when a test needs the
files or the request URLs rather than their contents.
`stylesheet_link_tags()` returns the `<link>` markup for a synthetic page
driven through Playwright, so it gets the whole shipped cascade.
- All of them fail loudly if `index.html` links a stylesheet that is missing.
- Not for vendored CSS under `static/lib/`, which they deliberately skip.
### `tests.helpers.cli_loader.load_script`
Use when a test needs to import a script under `scripts/` without repeating
+171
View File
@@ -0,0 +1,171 @@
"""The isolation contract of `odysseus dev`.
The launcher exists so that two checkouts on one machine cannot share
runtime state by accident. Every test here pins one of the guarantees
that makes that true: derived ports never land on a port the project
already means something by, a ChromaDB we did not start is refused
rather than adopted, a checkout wired into a service manager is not
bootable, and nothing is signalled on the strength of a pid alone.
"""
import argparse
import os
import socket
import pytest
from tests.helpers.cli_loader import load_script
@pytest.fixture
def cli():
return load_script("odysseus-dev")
@pytest.fixture
def worktree(tmp_path):
"""A directory shaped enough like a checkout for the launcher to accept it."""
for marker in ("app.py", "setup.py", "requirements.txt"):
(tmp_path / marker).write_text("")
(tmp_path / "venv" / "bin").mkdir(parents=True)
(tmp_path / "venv" / "bin" / "python").write_text("")
return tmp_path
def up_args(**overrides):
defaults = dict(
port=None, chroma_port=None, no_chroma=False, venv=None, from_pr=None,
remote="origin", foreground=False, timeout=5, pretty=False,
)
defaults.update(overrides)
return argparse.Namespace(**defaults)
def test_derived_ports_are_stable_distinct_and_never_reserved(cli):
first = cli.derive_ports("/checkouts/alpha")
assert first == cli.derive_ports("/checkouts/alpha")
assert first != cli.derive_ports("/checkouts/beta")
assert first["chroma"] == first["app"] + 1
assert first["test_static"] == first["app"] + 2
# No path can derive onto a port the project already owns — 7860 is a
# normal start-macos.sh launch, 8100 somebody else's vector store.
for index in range(500):
for port in cli.derive_ports(f"/checkouts/w{index}").values():
assert cli.reserved_reason(port) is None, port
def test_reserved_ports_are_refused_even_when_asked_for(cli, worktree, monkeypatch):
monkeypatch.chdir(worktree)
with pytest.raises(SystemExit):
cli.resolve_ports(worktree, up_args(port=7860))
with pytest.raises(SystemExit):
cli.resolve_ports(worktree, up_args(chroma_port=8100))
def test_root_is_resolved_from_the_working_directory(cli, worktree):
nested = worktree / "static" / "js"
nested.mkdir(parents=True)
assert cli.find_repo_root(nested) == worktree.resolve()
assert cli.find_repo_root(worktree.parent) is None
def test_a_checkout_run_by_a_service_manager_is_not_bootable(cli, worktree, monkeypatch):
units = worktree.parent / "units"
units.mkdir()
(units / "com.odysseus.server.plist").write_text(
f"<plist><string>{worktree.resolve()}/start-macos.sh</string></plist>"
)
assert cli.managed_by_service(worktree, unit_dirs=[units]).endswith(".plist")
unrelated = worktree.parent / "somewhere-else"
unrelated.mkdir()
assert cli.managed_by_service(unrelated, unit_dirs=[units]) is None
monkeypatch.chdir(worktree)
monkeypatch.setattr(cli, "service_unit_dirs", lambda: [units])
with pytest.raises(SystemExit):
cli.cmd_up(up_args())
def test_a_chromadb_we_did_not_start_is_refused_not_adopted(cli, worktree, monkeypatch):
monkeypatch.chdir(worktree)
monkeypatch.setattr(cli, "service_unit_dirs", list)
ports = cli.derive_ports(worktree)
foreign = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
foreign.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
foreign.bind(("127.0.0.1", ports["chroma"]))
except OSError:
pytest.skip(f"derived chroma port {ports['chroma']} is unavailable on this host")
foreign.listen(1)
try:
with pytest.raises(SystemExit):
cli.cmd_up(up_args())
finally:
foreign.close()
# Refused means refused: nothing was started and no state was recorded.
assert not cli.state_path(worktree).exists()
def test_no_chroma_points_the_app_at_a_port_nothing_answers(cli):
port = cli.unused_port()
assert not cli.port_bound(port)
assert cli.reserved_reason(port) is None
def test_every_chroma_outcome_leaves_the_app_off_a_port_we_do_not_own(cli, worktree):
"""The decision has three endings and none of them is "use theirs"."""
ports = cli.derive_ports(worktree)
data, logs = worktree / "data", worktree / "logs"
data.mkdir()
logs.mkdir()
venv_python = worktree / "venv" / "bin" / "python"
# 1. Asked to go without: a port nothing answers on, not the derived
# one, which is where a foreign server may appear later.
entry, port, note = cli.resolve_chroma(
up_args(no_chroma=True), {}, ports, venv_python, data, logs
)
assert (entry, port != ports["chroma"], cli.port_bound(port)) == (None, True, False)
assert "no-chroma" in note
# 2. No server to start (this venv has no `chroma` binary, which is
# the stock requirements.txt): keyword mode, and again not the
# derived port.
entry, port, note = cli.resolve_chroma(
up_args(), {}, ports, venv_python, data, logs
)
assert entry is None and port != ports["chroma"]
assert "keyword-only" in note
def test_a_pid_is_never_trusted_without_its_command_line(cli, worktree, monkeypatch):
own_pid = os.getpid()
assert cli.pid_is_ours(own_pid, ["definitely-not-in-this-command-line"]) is False
assert cli.pid_is_ours(None, []) is False
assert cli.pid_is_ours(own_pid, [cli.pid_command(own_pid).split()[0]]) is True
# `down` must not signal a live process whose fingerprints disagree —
# here, this very test run — and must keep the record so the pid can
# be investigated rather than lost.
cli.write_state(worktree, {"app": {"pid": own_pid, "fingerprints": ["uvicorn --port 1"]}})
monkeypatch.chdir(worktree)
monkeypatch.setattr(os, "kill", _forbidden_kill)
cli.cmd_down(up_args())
assert cli.state_path(worktree).exists()
def test_down_forgets_an_instance_that_is_gone(cli, worktree, monkeypatch):
cli.write_state(worktree, {"app": {"pid": 2 ** 31 - 1, "fingerprints": ["uvicorn"]}})
monkeypatch.chdir(worktree)
cli.cmd_down(up_args())
assert not cli.state_path(worktree).exists()
def _forbidden_kill(pid, sig):
"""Liveness probes (signal 0) are fine; anything that would actually
reach the process is the failure this test is about."""
if sig == 0:
return None
raise AssertionError(f"cmd_down sent signal {sig} to a process it does not own")

Some files were not shown because too many files have changed in this diff Show More