17 Commits

Author SHA1 Message Date
Michael e8175c9535 fix: Images cannot be seen by model that is vision capable (#4726)
* fix: Images cannot be seen by model that is vision capable

* fix: skip http(s) image_url for Ollama (images[] is base64-only)

---------

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-23 10:32:57 +02:00
aubrey bd9149f79a fix(llm): detect mistral.ai provider and support reasoning_effort (#4698)
* fix(llm): detect mistral.ai provider and support reasoning_effort

Four coupled bugs broke Mistral thinking model support:

1. _detect_provider() had no mistral.ai host check, so all Mistral
   endpoints fell through to the generic 'openai' provider string.
   _provider_display_name() correctly identified them as 'Mistral',
   making any 'if provider == "Mistral"' check elsewhere dead code.

2. reasoning_effort parameter was never sent in the request payload,
   so Mistral never activated thinking mode even when the user
   configured a thinking-capable model (mistral-small-latest,
   mistral-medium-latest, magistral-*).

3. Mistral returns content as a typed array
   ([{"type":"thinking",...},{"type":"text",...}]) when
   reasoning is on, not as a plain string. Both the streaming and
   non-streaming parsers expected strings and silently dropped the
   thinking content.

4. _THINKING_MODEL_PATTERNS didn't include magistral or mistral-*
   model prefixes, so the frontend wouldn't tag reasoning output
   as thinking even after the above were fixed.

Fix:
- Add mistral.ai to _detect_provider() host checks
- Add a _normalize_mistral_content() helper that splits the typed
  array into (text, thinking) strings
- Inject payload["reasoning_effort"] = "high" when provider is
  Mistral and _supports_thinking(model) is true, in both stream_llm
  and llm_call_async payload construction
- Wire the normalizer into both response parsers
- Extend _THINKING_MODEL_PATTERNS to include magistral,
  mistral-small, mistral-medium, mistral-large

Tested on Docker install with mistral-small-latest +
reasoning_effort=high. Reasoning streams correctly into the
thinking panel after the fix.

Fixes #4678

* fix(llm): address review — lowercase provider id, configurable effort, tests

Addresses vdmkenny's review on PR #4698:

1. Removed duplicate 'if provider == "mistral"' block in stream_llm
   — two back-to-back copies, one was dead-redundant.

2. Dropped personal-context comment ('free-tier limits are generous
   for this user') and made reasoning_effort configurable via env var
   ODYSSEUS_MISTRAL_REASONING_EFFORT (high / medium / low / none).
   Default remains 'high' for backward compat with the tested behavior.

3. Recased provider id from 'Mistral' to 'mistral' to match the
   lowercase convention used by every other provider id in the file
   (openai, anthropic, ollama, copilot, ...). _provider_display_name()
   still returns the Title-Case 'Mistral' for UI labels — only the
   runtime id used in 'if provider == ...' checks was recased.

4. Added tests/test_llm_core_mistral_content.py with 13 tests pinning
   _normalize_mistral_content()'s contract: string passthrough, the
   Mistral array format (thinking + text blocks), and edge cases
   (empty, garbage, None, wrong types, missing fields, string-vs-array
   inner thinking field).

Also fixed a gap the review didn't catch: the non-streaming paths
(llm_call sync + llm_call_async) were missing the reasoning_effort
injection entirely. Added the same injection to both, so Deep Research
and agent tool calls also activate Mistral thinking.

All 13 new tests pass. Existing reasoning/streaming/ollama-thinking
tests still pass (38 tests, no regressions).

Fixes #4678
2026-06-23 10:28:17 +02:00
Max Hsu fef08ed114 fix(modal): keep body-portaled dropdowns above their tool modal at any stack depth (#4720) (#4724)
* fix(memory): keep the Brain memory item menu above the modal at any stack depth

The memory item "⋮" dropdown is portaled to <body> with a hardcoded
z-index of 10001. Tool modals, however, get a monotonically increasing
z-index from modalManager's bring-to-front counter (_modalTopZ), which
climbs unbounded as modals are opened/restored over a session. Once that
counter passes 10001, the Brain modal stacks above the body-portaled
dropdown, so the menu renders behind the panel — visible only where it
spills past the modal's edge (#4720).

Derive the dropdown's z-index from the owning modal's current z-index
(+1), keeping 10001 as a floor for the common low-counter case, so the
menu always sits just above its modal however high the counter has climbed.

Verified with document.elementFromPoint at the dropdown's location: with a
high modal z-index the old build returns the modal at every sampled point
(menu behind); the fixed build returns the dropdown (menu on top). The
default low-counter case is unchanged (z stays 10001).

* refactor(modal): route body-portaled dropdowns through a shared topPortalZ() helper

The hardcoded z-index:10001 the Brain memory menu used (#4720) is the same
literal shared by ~16 body-portaled dropdowns across calendar, cookbook,
cookbookServe, documentLibrary, emailLibrary, gallery, notes, emojiPicker and
memory — each renders behind its owning tool modal once modalManager's
bring-to-front counter climbs past the literal over a long session.

Promote the per-dropdown fix into a single topPortalZ() helper in
toolWindowZOrder.js — the existing source of truth for tool-window z, already
imported by modalManager's _bringToFront and notes.js — returning
max(topToolWindowZ(), dock-chip floor) + 1, so a portaled dropdown always sits
just above the live tool-window stack however high the counter has climbed.
Route all 16 sites through it. The slashCommands tour tooltips and the
cookbookServe VRAM dialog are intentionally left out (neither is a modal-owned
portaled dropdown).

Add tests/test_portal_dropdown_z_js.py covering the helper, including the #4720
scenario (modal counter at 99999 -> dropdown at 100000). Existing
test_notes_z_order_js.py stays green.
2026-06-23 10:24:31 +02:00
nopoz 7e5db9a3c6 fix(security): redact credential-bearing URLs and PII from logs (#4750)
* fix(security): redact credential-bearing URLs and PII from logs

Several log statements emitted sensitive data in clear text:

- model_routes / chat_routes / contacts_routes logged endpoint URLs raw.
  Admin-configured URLs can embed credentials in userinfo or query
  (e.g. https://user:pass@host, ?api_key=...). Route them through a
  shared core.log_safety.redact_url() that drops userinfo/query/fragment.
- note_routes / task_scheduler logged operator email addresses (smtp_user,
  recipient). Replaced with presence booleans, which keeps the diagnostic
  ("why didn't this send") without writing PII to logs.

model_routes already had a local redactor on its HTTPStatusError branch;
the generic except branch was missed, so reuse the existing helper there.

Clears CodeQL py/clear-text-logging-sensitive-data alerts 264, 317, 324,
325, 343, 344, 528.

* fix(security): re-bracket IPv6 hosts and single-source the URL redactor

Address review on #4750:
- redact_url now re-brackets IPv6 literals so host:port stays
  unambiguous (https://[2001:db8::1]:8443/v1, not the bracket-less
  ambiguous form).
- point model_routes._redact_url_for_log at the shared helper so the
  two redactors are single-sourced (also picks up the IPv6 fix).
2026-06-22 23:12:39 +02:00
nopoz 2f246c7779 fix(security): escape backslashes in calendar bg-image CSS url() (#4712)
* fix(security): escape backslashes in calendar bg-image CSS url()

The calendar event-background CSS escaped ' -> \' for a bg: image URL but
not backslashes first. Inside a single-quoted url('...'), \ is the CSS
escape char, so a URL value ending in/containing a backslash escapes the
closing quote and breaks out of the string, injecting arbitrary CSS. The
bg:<url> value is per-event and CalDAV-syncable, hence untrusted (CodeQL
js/incomplete-sanitization).

Add a single canonical _cssUrlEscape() in calendar/utils.js that escapes
backslashes FIRST, then quotes, and route all four sinks through it:
calendar.js:416 / :1263 (the flagged #463/#464), the event-form preview
(:2931), and _calBgCss() in utils.js — the latter two share the identical
bug but were unflagged. Output is byte-identical to the old escaping for
legitimate URLs (which contain no backslashes); only malicious input differs.

Resolves CodeQL js/incomplete-sanitization #463, #464.

* fix(security): route remaining calendar bg url() sinks through _cssUrlEscape

Review (vdmkenny) flagged that the centralization missed an injectable
sibling sink: the edit-form color-picker swatch (calendar.js:2856) built
`url('${url}')` from `existing.color` (a CalDAV-syncable, untrusted `bg:`
value) raw, then interpolated it into `style="background:..."` via innerHTML
- the same `'`/`\` breakout class as the sinks already fixed. The custom-dot
preview (:2953) was likewise raw (non-exploitable - a CSSOM `.style`
assignment of a URL the current user just picked - but it broke the invariant).

Route both through `_cssUrlEscape`, and normalize the two pre-escaped-variable
sites (_calItemBgStyle, _renderWeek) to the same inline form so all five
url() interpolations in calendar.js follow one rule. Add a whole-file
invariant test asserting every `url('${...}')` calls `_cssUrlEscape` - this
catches a future missed sink, the exact failure mode here. Behavior-identical
for legitimate URLs (no visual change).
2026-06-22 21:17:52 +02:00
Rudra Sarker 8ec27fd903 fix: document read fails with 403 when auth is disabled (#4623)
* fix: document read fails with 403 when auth is disabled

Add _auth_disabled() bypass in _verify_doc_owner() and the
/api/documents/{session_id} route guard so documents remain accessible
in single-user / no-auth mode.

Minimal change: only adds the auth-disabled check alongside existing
403 raises — preserves existing formatting and line endings.

* refactor: hoist _auth_disabled import to module level

Address reviewer feedback on PR #4623 — no circular import exists
(src.auth_helpers only imports stdlib + fastapi), so the inline
imports are unnecessary. Moves the import to module top in both
document_helpers.py and document_routes.py.

* test: add regression tests for auth-disabled document access (PR #4623)
2026-06-22 21:01:11 +02:00
MACKAT05 b57989f08c fix(hwfit): repair remote Windows hardware scan over SSH (#4674)
Remote Cookbook hwfit probes failed on Windows hosts because the PowerShell script was sent as nested -Command quoting through OpenSSH. Use -EncodedCommand for remote probes, auto-detect platform when omitted (including Darwin for Mac SSH hosts), and return a clearer error when SSH works but the probe fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 20:59:09 +02:00
Gabriel Peña 91bba117c1 fix ask-user choices across reloads (#4669) 2026-06-22 20:49:49 +02:00
Mocchibird 4c82e4a172 fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684)
The app's ad-hoc dropdown/context menus each wire their own document-level
outside-click listener, but that listener only removes itself on an *outside*
click. Every other dismissal path -- clicking a menu item (which calls
el.remove() directly), a Cancel button, Escape, or the "close the
previously-open menu" reopen sweep -- tears the node down without
unregistering the listener, orphaning it on `document`. The stranded listener
then lingers and can break the next menu interaction: the recurring "the
button stops working until I refresh the page" class of bug (e.g. delete an
email, then the kebab/more button is dead on the other rows).

Route all 16 of these menus through the existing escMenuStack helper
(bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js
_showLibDropdown, cookbookRunning.js, and research/panel.js already do: a
single idempotent close() owns the teardown and is released on every dismissal
path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and
Escape flows through the central LIFO esc-stack arbiter. Net -49 lines.

Menus migrated: cookbook _showDepMenu; document export menu and
_openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary
_showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery
_showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings
(3 unified-integrations dropdowns); skills _openSkillMenu; tasks
_showTaskDropdown; compare _toggleExportMenu.

Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms
ghost-click guard, emailLibrary's reader-more-active anchor class and the
bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles).

Six menus with custom lifecycles (notes _openReminderMenu, sessions
long-press, document markdown-toolbar, emojiPicker, compare model selector)
are intentionally left for a follow-up -- each needs individual review.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
Ahmed Dlshad b899095f18 docs(setup): note -BindHost flag for LAN access on native Windows (#4636)
The native Windows launcher binds to 127.0.0.1 via its own -BindHost
parameter and does not read APP_BIND/ODYSSEUS_HOST from .env, so editing
.env alone leaves the server on loopback. Document the -BindHost flag in
the Native Windows setup section, with the existing keep-auth-on /
don't-expose-publicly caveats.

Fixes #4552

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:29:55 +02:00
Mostafa Eid 888e25624f fix(sessions): prevent Backspace/Delete from deleting session while renaming (#4662) 2026-06-22 20:22:52 +02:00
comatrix-1 c062c27648 Fix link to CONTRIBUTING.md in setup documentation (#4677) 2026-06-22 20:12:04 +02:00
holden093 93ec7cbb52 fix(contacts): verify UID removal after CardDAV DELETE (#4642)
Add a post-delete verification step: after the CardDAV server returns
2xx/404, force-re-fetch the contact list and confirm the UID is gone.
If the UID is still present, log a warning and return False instead of
silently reporting success.

This catches the case where _resolve_resource_url falls back to the
guessed {uid}.vcf URL but the contact's real resource URL differs —
the DELETE hits the wrong URL, server returns 404 (treated as success),
but the contact remains. Previously this caused silent persistence
failures and agent loops.
2026-06-22 18:39:44 +02:00
ooovenenoso c12b8ab6c9 fix: add OpenCode setup provider aliases (#4700)
Co-authored-by: Kevin <120500656+oooindefatigable@users.noreply.github.com>
2026-06-22 17:33:02 +02:00
Ashvin e812a29233 fix(markdown): preserve URLs inside inline code spans (#4681)
Inline backtick spans were converted to <code> only at the end of
mdToHtml, after the bare-URL autolink and <a>/allowed-HTML passes. A URL
inside inline code is preceded by a space, so the autolink wrapped it in
an <a> tag and swapped it for an ___ALLOWED_HTML_ placeholder, corrupting
commands like `irm http://127.0.0.1:3000/x`.

Extract inline code into placeholders before the link passes, mirroring
the existing fenced-code-block handling, and restore them last so
placeholders carried inside restored <a> blocks resolve. Escape the code
at extraction time since it now bypasses the global escape pass.
2026-06-22 17:23:55 +02:00
nopoz ca4973c41f fix(security): prevent exponential ReDoS in email→calendar extract regex (#4708)
The fallback regex in email_pollers.py that recovers a
[{"action": ...}, ...] JSON array from raw model output used lazy
[^[\]]*? runs inside a (?:,\s*\{...\}\s*)* repetition, which backtracks
exponentially (CodeQL py/redos) on inputs like [{"action"},{ + }},{{ * N.
It runs on the LLM reply to an email→calendar prompt embedding the
untrusted email body, so a crafted email can stall the background poller.

Extract the pattern to a module-level _CAL_ACTION_ARRAY_RE and rewrite the
object-content class from the lazy [^[\]]*? to a greedy brace-delimited
[^{}], which removes the quantifier ambiguity. The match is linear (a 500KB
adversarial input now resolves in <1ms) and equivalent on well-formed
arrays; it is also strictly more robust for values containing '[' or ']'
(the old class bailed on those and extracted nothing).

Resolves CodeQL py/redos #198.
2026-06-22 17:18:34 +02:00
Tom 91b4171b3f feat(a11y): add a Text size control and an OpenDyslexic font option (#4210)
* feat(a11y): add a Text size control and an OpenDyslexic font option

Text size: a Theme > Font & Layout control (Default / Larger) that scales the whole UI via CSS zoom, so the many hard-coded px sizes scale too (density only moves the root font-size). Stored globally so it persists across theme switches; applied early in the boot script to avoid a flash. OpenDyslexic: a dyslexia-friendly self-hosted font (SIL OFL 1.1), bundled as woff2 alongside Fira Code/Inter and wired into the Font select. Reuses the existing density/font pattern end to end; no new colours, spacing, or component styles.

* fix(a11y): keep modals on-screen at Larger text size

Inline vh heights on .modal-content overrode the ui-scale-125 max-height
compensation, so Cookbook (and the email/doc/skills/PDF modals) overflowed
the viewport at 125% — pushing the header and close button off-screen.
Let the compensation own those heights.

* fix(a11y): keep PDF export modal at its original 86vh on Default size
2026-06-22 13:53:46 +02:00
57 changed files with 1901 additions and 412 deletions
+1
View File
@@ -86,6 +86,7 @@ Bundled in `static/fonts/`:
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
## Python dependencies
+27
View File
@@ -0,0 +1,27 @@
"""Helpers for keeping sensitive data out of logs.
Endpoint URLs configured by admins can embed credentials in the userinfo
(``https://user:pass@host``) or query string (``?api_key=...``). Logging them
raw leaks those secrets, so route/diagnostic logs run URLs through
``redact_url`` first. Reconstructing the URL without userinfo/query/fragment
also doubles as a sanitizer barrier for CodeQL's clear-text-logging query.
"""
from urllib.parse import urlparse, urlunparse
def redact_url(url: str) -> str:
"""Return a URL safe for logs by removing userinfo and query/fragment.
Keeps scheme, host, port and path so logs stay useful for debugging.
"""
try:
parsed = urlparse(url or "")
host = parsed.hostname or ""
if ":" in host: # IPv6 literal — re-bracket so host:port stays unambiguous
host = f"[{host}]"
if parsed.port:
host = f"{host}:{parsed.port}"
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
except Exception:
return "<endpoint>"
+14 -1
View File
@@ -15,7 +15,7 @@ On first setup, Odysseus creates an admin account (`admin` unless
For Docker installs, the same line is in `docker compose logs odysseus`.
Use that for the first login, then change it in **Settings**.
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md) for setup, testing, and
pull request guidelines.
### Docker (recommended)
@@ -250,6 +250,19 @@ python -m uvicorn app:app --host 127.0.0.1 --port 7000
If `python` points at an older interpreter, use `py -3.12` (or another installed
3.11+ version) for the venv step.
**Exposing on a LAN/Tailscale (Windows):** the launcher binds to `127.0.0.1` and
does **not** read `APP_BIND` / `ODYSSEUS_HOST` from `.env`, so editing `.env`
alone leaves the native Windows server on loopback. Pass the launcher's
`-BindHost` flag instead:
```powershell
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1 -BindHost 0.0.0.0
```
The manual `uvicorn` command takes the same address as `--host 0.0.0.0`. Bind
outside loopback only for a trusted LAN/VPN such as Tailscale: keep
`AUTH_ENABLED=true` and do not expose the port directly to the public internet.
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
email, calendar, deep research) runs fully native. For full **Cookbook** background
model downloads and the agent shell tool, also install
+94
View File
@@ -0,0 +1,94 @@
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+2 -1
View File
@@ -29,6 +29,7 @@ from routes.document_helpers import _owner_session_filter
from core.database import SessionLocal, get_session_mode, set_session_mode
from core.database import Session as DBSession, ChatMessage as DBChatMessage
from core.database import Document as DBDocument, ModelEndpoint
from core.log_safety import redact_url
from routes.research_routes import _resolve_research_endpoint
from routes.model_routes import _visible_models
from routes.chat_helpers import (
@@ -930,7 +931,7 @@ def setup_chat_routes(
if effective_do_research:
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
_auth_keys = list(_r_headers.keys()) if _r_headers else []
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={_r_ep}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={redact_url(_r_ep)}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
# Clarification round: only for very short/vague queries on first research message.
# Skip in compare mode — each pane is a fresh session, so every one would
+18 -8
View File
@@ -18,6 +18,7 @@ from pathlib import Path
from datetime import datetime
from urllib.parse import urljoin, urlparse, urlunparse
from core.log_safety import redact_url
from fastapi import APIRouter, Query, Depends, Response, HTTPException
from typing import List, Dict, Optional
@@ -689,15 +690,24 @@ def _delete_contact(uid: str) -> bool:
url = _resolve_resource_url(uid)
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
r = httpx.delete(url, auth=auth, timeout=10)
if r.status_code in (200, 204):
_contact_cache["fetched_at"] = None
return True
if r.status_code == 404:
# Resource not found at the resolved URL. With href resolution
# this should be rare (genuinely already deleted). Invalidate
# the cache and report success so the UI doesn't keep a ghost.
logger.info(f"CardDAV DELETE 404 for {uid} — treating as already gone")
if r.status_code in (200, 204, 404):
# Invalidate cache so the next fetch sees the server truth.
_contact_cache["fetched_at"] = None
# Verify: force a fresh fetch and check the UID is actually gone.
# A 404 on the guessed URL ({uid}.vcf) can mean the contact
# lives at a different resource URL — the DELETE missed it but
# we'd silently report success. This check catches that.
fresh = _fetch_contacts(force=True)
still_there = any(c.get("uid") == uid for c in fresh)
if still_there:
logger.warning(
f"CardDAV DELETE reported success for {uid} "
f"but UID still present after re-fetch — "
f"resource URL may differ from {redact_url(url)}"
)
return False
if r.status_code == 404:
logger.info(f"CardDAV DELETE 404 for {uid} — already gone")
return True
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
return False
+3 -1
View File
@@ -12,6 +12,7 @@ from pydantic import BaseModel
from core.database import Document, DocumentVersion
from core.database import Session as DbSession
from src.auth_helpers import _auth_disabled
from src.upload_handler import UploadHandler
logger = logging.getLogger(__name__)
@@ -78,6 +79,8 @@ def _verify_doc_owner(db, doc: Document, user: str):
the session join for any not-yet-backfilled legacy row.
"""
if user is None:
if _auth_disabled():
return # Single-user / no-auth mode: allow access
raise HTTPException(403, "Authentication required")
if doc.owner is not None:
if doc.owner != user:
@@ -104,7 +107,6 @@ def _owner_session_filter(q, user):
by the time this filter is live there are no NULL-owner rows to leak;
we therefore match the owner strictly for authenticated callers."""
if not user:
from src.auth_helpers import _auth_disabled
if user == "" or _auth_disabled():
return q
return q.filter(False)
+3 -2
View File
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File,
from sqlalchemy import case, func, or_
from core.database import SessionLocal, Document, DocumentVersion
from core.database import Session as DbSession
from src.auth_helpers import get_current_user
from src.auth_helpers import get_current_user, _auth_disabled
from src.constants import MAIL_ATTACHMENTS_DIR
logger = logging.getLogger(__name__)
@@ -388,7 +388,8 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
db = SessionLocal()
try:
if not user:
raise HTTPException(403, "Authentication required")
if not _auth_disabled():
raise HTTPException(403, "Authentication required")
# v2 review HIGH-9: raise 403 explicitly when the caller
# can't see this session, instead of returning [] which the
# UI treats identically to "no docs" and silently masks
+12 -1
View File
@@ -44,6 +44,17 @@ from routes.email_helpers import (
logger = logging.getLogger(__name__)
# Recovers a `[{"action": ...}, ...]` JSON array from raw LLM output when the
# fenced-block strip leaves nothing usable. Runs on model output influenced by
# untrusted email bodies, so it must not backtrack: the object content class is
# `[^{}]` (brace-delimited, greedy) rather than the old `[^[\]]*?` lazy runs,
# which exploded exponentially on inputs like `[{"action"},{` + `}},{{` * N
# (CodeQL py/redos #198).
_CAL_ACTION_ARRAY_RE = re.compile(
r'\[\s*\{[^{}]*"action"[^{}]*\}\s*(?:,\s*\{[^{}]*\}\s*)*\]',
re.DOTALL,
)
def _owner_for_email_account(account_id: str | None) -> str:
if not account_id:
@@ -558,7 +569,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
cal_extract = _strip_think(_raw_original)
cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip()
if not cal_extract and _raw_original:
matches = list(re.finditer(r'\[\s*\{[^[\]]*?"action"[^[\]]*?\}\s*(?:,\s*\{[^[\]]*?\}\s*)*\]', _raw_original, re.DOTALL))
matches = list(_CAL_ACTION_ARRAY_RE.finditer(_raw_original))
if matches:
cal_extract = matches[-1].group()
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
+3 -14
View File
@@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Respon
from pydantic import BaseModel
from fastapi.responses import StreamingResponse
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
from core.log_safety import redact_url as _redact_url_for_log
from core.middleware import require_admin
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
from src.tls_overrides import llm_verify
@@ -582,18 +583,6 @@ def _safe_build_headers(api_key: Optional[str], base_url: str) -> dict:
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
def _redact_url_for_log(url: str) -> str:
"""Return a URL safe for logs by removing userinfo and query/fragment."""
try:
parsed = urlparse(url or "")
host = parsed.hostname or ""
if parsed.port:
host = f"{host}:{parsed.port}"
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
except Exception:
return "<endpoint>"
def _is_discovery_only_provider(provider: str) -> bool:
return provider == "chatgpt-subscription"
@@ -810,9 +799,9 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
except Exception as e:
if api_key:
logger.warning(f"Failed to probe {url} with API key: {e}")
logger.warning("Failed to probe %s with API key: %s", _redact_url_for_log(url), e)
return []
logger.warning(f"Failed to probe {url}: {e}")
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
# Older Ollama builds and some proxies expose native /api/tags even when
# the OpenAI-compatible /v1/models path is unavailable.
+5 -4
View File
@@ -335,10 +335,11 @@ async def dispatch_reminder(
# Loud diagnostic so we can see WHY a reminder didn't send (the
# previous "silently no-op when cfg has no smtp_host" was invisible).
logger.info(
f"dispatch_reminder[email] note_id={note_id} owner={owner!r} "
f"smtp_host={cfg.get('smtp_host')!r} smtp_user={cfg.get('smtp_user')!r} "
f"from={from_addr!r} recipient={recipient!r} "
f"account_name={cfg.get('account_name')!r}"
"dispatch_reminder[email] note_id=%s owner=%r "
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
note_id, owner,
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
bool(from_addr), bool(recipient),
)
missing = []
if not cfg.get("smtp_host"):
+37 -5
View File
@@ -538,6 +538,32 @@ def _powershell_exe():
path so we don't depend on a particular PATH ordering."""
return shutil.which("pwsh") or shutil.which("powershell") or "powershell"
def _powershell_encoded_for_ssh(script: str):
"""Run a PowerShell script on a remote Windows host over SSH.
Nested quotes in powershell -Command break when passed through Windows
OpenSSH's cmd wrapper; -EncodedCommand avoids that.
"""
import base64
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
return _run(f"powershell -NoProfile -EncodedCommand {encoded}")
def _probe_remote_platform():
"""Best-effort OS detection over SSH when the caller didn't pass platform."""
out = _run("echo %OS%")
if out and "Windows_NT" in out:
return "windows"
uname = (_run(["uname", "-s"]) or "").strip().lower()
if uname == "darwin":
# Mac uses the linux detection path (_detect_apple_silicon over SSH).
return "linux"
if uname == "linux":
out = _run("test -d /data/data/com.termux && echo termux || echo linux")
if out and "termux" in out:
return "termux"
return "linux"
def _detect_windows():
"""Detect Windows hardware via PowerShell/WMI.
@@ -600,9 +626,8 @@ def _detect_windows():
"""
)
if _remote_host:
# Remote: ship a single command string over SSH. The remote shell parses
# the quoting; PowerShell on the far side runs the -Command payload.
out = _run(f'powershell -Command "{ps_cmd}"')
# Remote: use -EncodedCommand so OpenSSH/cmd quoting does not break the script.
out = _powershell_encoded_for_ssh(ps_cmd.strip())
else:
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
# to PowerShell verbatim — no fragile string-level quote escaping. Prefer
@@ -773,6 +798,13 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
"""
global _remote_host, _remote_port, _remote_platform
if host and not platform:
_remote_host = host
_remote_port = ssh_port or None
platform = _probe_remote_platform()
_remote_host = None
_remote_port = None
cache_key = _cache_key(host, ssh_port, platform)
now = time.time()
if not fresh and cache_key in _cache_by_host:
@@ -793,8 +825,8 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
return result
# If Windows detection failed, return error
result = {"error": f"Cannot connect to {host}", "host": host}
# SSH may work while the PowerShell hardware probe still fails.
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
_remote_host = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
+24 -6
View File
@@ -3215,9 +3215,12 @@ async def stream_agent_loop(
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
)
# ask_user: the agent posed a multiple-choice question. Emit it so the
# frontend renders clickable options, then end the turn (below) and
# wait — the user's pick becomes the next message.
# ask_user: remember the payload now, but emit the interactive event
# only *after* tool_output below. Emitting it before tool_output let
# the subsequent tool-card rewrite/scroll push the choices out of
# view. The payload is also copied into the persisted tool event so
# history reload can reconstruct an unanswered card.
_pending_ask_user_event = None
if "ask_user" in result:
# The question lives in the tool args. ChatMessage.to_dict()
# replays only role+content to the model next turn — tool_event
@@ -3232,9 +3235,7 @@ async def stream_agent_loop(
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
full_response += _auq_delta
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
yield (
f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n'
)
_pending_ask_user_event = _auq
_awaiting_user = True
# update_plan: agent wrote back to the plan (ticked a step / revised).
@@ -3289,6 +3290,10 @@ async def stream_agent_loop(
# Emit tool_output (include ui_event data if present)
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
if _pending_ask_user_event:
# Keep enough state in the streamed tool result for alternate
# clients to render the prompt without depending on event order.
tool_output_data["ask_user"] = _pending_ask_user_event
if "ui_event" in result:
tool_output_data["ui_event"] = result["ui_event"]
for k in (
@@ -3319,6 +3324,14 @@ async def stream_agent_loop(
tool_output_data["diff"] = result["diff"]
yield f'data: {json.dumps(tool_output_data)}\n\n'
# This must be the final UI event for ask_user: the frontend appends
# the card below the now-settled tool node and cancels any between-
# round spinner. The turn ends after the current tool batch.
if _pending_ask_user_event:
yield (
f'data: {json.dumps({"type": "ask_user", "data": _pending_ask_user_event})}\n\n'
)
# Native document tools open in the editor + carry the REAL doc id.
# Emit a doc_update so the frontend opens/activates it and sends it
# back as active_doc_id next turn (otherwise the agent can't "see"
@@ -3376,6 +3389,11 @@ async def stream_agent_loop(
# this the diff shows live but vanishes from saved history.
if result.get("diff"):
tool_event["diff"] = result["diff"]
if _pending_ask_user_event:
# Persist the structured question with the tool event. On a
# reload, chatRenderer can restore the card; a later user
# message removes it as answered.
tool_event["ask_user"] = _pending_ask_user_event
tool_events.append(tool_event)
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
_effectful_used = True
+155 -28
View File
@@ -345,43 +345,102 @@ def _normalize_ollama_url(url: str) -> str:
return base.rstrip("/") + "/chat"
def _ollama_normalize_tool_messages(messages: List[Dict]) -> List[Dict]:
def _ollama_normalize_messages(messages: List[Dict]) -> List[Dict]:
"""Adapt Odysseus' canonical OpenAI-style messages to native Ollama /api/chat.
Odysseus carries assistant tool calls in the OpenAI shape, where
`function.arguments` is a JSON *string*. Native Ollama expects it to be a
JSON *object*; given the string it fails the whole request with HTTP 400
"Value looks like object, but can't find closing '}' symbol", which aborts
every follow-up (tool-result) round. Parse the arguments back into an object
here, on a shallow copy, leaving non-tool messages untouched. The opaque
Gemini `extra_content` (thought_signature) is dropped it is meaningless to
Ollama and only matters when the conversation is replayed to Gemini.
Two shape mismatches silently break requests:
1. Tool calls: Odysseus carries `function.arguments` as a JSON *string*.
Native Ollama expects a JSON *object* and rejects the string form with
HTTP 400 ("Value looks like object, but can't find closing '}' symbol"),
aborting every follow-up (tool-result) round. Parse the arguments back
into an object here, on a shallow copy, leaving non-tool messages
untouched. The opaque Gemini `extra_content` (thought_signature) is
dropped it is meaningless to Ollama and only matters when the
conversation is replayed to Gemini.
2. Images (issue #4723): Odysseus carries multimodal user content as an
OpenAI-style list ``[{type: "text", ...}, {type: "image_url",
image_url: {url: "data:image/...;base64,XXX"}}, ...]``. Native Ollama
does not accept a list for ``content`` it wants ``content`` as a
string plus a separate ``images`` array of raw base64 strings (no
``data:`` prefix). Without this conversion the image blocks pass
through untouched, the vision-capable model never sees the picture,
and the user gets "I can't see any image" even though the request
succeeded.
"""
out: List[Dict] = []
for m in messages or []:
tcs = m.get("tool_calls") if isinstance(m, dict) else None
if not tcs:
if not isinstance(m, dict):
out.append(m)
continue
new_calls = []
for tc in tcs:
fn = tc.get("function") or {}
args = fn.get("arguments")
if isinstance(args, str):
try:
args = json.loads(args) if args.strip() else {}
except (json.JSONDecodeError, TypeError):
args = {}
call: Dict = {"function": {"name": fn.get("name", ""), "arguments": args or {}}}
if tc.get("id"):
call["id"] = tc["id"]
new_calls.append(call)
nm = dict(m)
nm["tool_calls"] = new_calls
# 1. Tool-call argument strings -> objects.
tcs = nm.get("tool_calls")
if tcs:
new_calls = []
for tc in tcs:
fn = tc.get("function") or {}
args = fn.get("arguments")
if isinstance(args, str):
try:
args = json.loads(args) if args.strip() else {}
except (json.JSONDecodeError, TypeError):
args = {}
call: Dict = {"function": {"name": fn.get("name", ""), "arguments": args or {}}}
if tc.get("id"):
call["id"] = tc["id"]
new_calls.append(call)
nm["tool_calls"] = new_calls
# 2. Multimodal content list -> native content string + images array.
content = nm.get("content")
if isinstance(content, list):
text_parts: List[str] = []
images: List[str] = list(nm.get("images") or [])
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "text":
t = block.get("text")
if t:
text_parts.append(str(t))
elif btype == "image_url":
url = (block.get("image_url") or {}).get("url", "")
if not url:
continue
if url.startswith("data:"):
# Strip the ``data:[...];base64,`` prefix — native
# Ollama wants only the base64 bytes.
_, _, b64 = url.partition(",")
if b64:
images.append(b64)
else:
# Native Ollama images[] is base64-only; it does
# not fetch HTTP URLs. Skip unsupported schemes
# rather than sending a non-base64 string that the
# model silently ignores.
logger.warning(
"Skipping non-data image_url (Ollama images[] "
"requires base64): %s",
url[:80],
)
nm["content"] = "\n".join(text_parts).strip()
if images:
nm["images"] = images
out.append(nm)
return out
# Backward-compatible alias for callers/tests that imported the older name
# (it only handled tool messages originally — issue #4723 broadened scope).
_ollama_normalize_tool_messages = _ollama_normalize_messages
def _build_ollama_payload(
model: str,
messages: List[Dict],
@@ -404,7 +463,7 @@ def _build_ollama_payload(
"""
payload: Dict = {
"model": model,
"messages": _ollama_normalize_tool_messages(messages),
"messages": _ollama_normalize_messages(messages),
"stream": stream,
}
options: Dict = {}
@@ -618,6 +677,8 @@ def _detect_provider(url: str) -> str:
from src.copilot import is_copilot_base
if is_copilot_base(url):
return "copilot"
if _host_match(url, "mistral.ai"):
return "mistral"
return "openai"
@@ -906,10 +967,17 @@ def _anthropic_rejects_temperature(model: str) -> bool:
return False
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
# API accepts "high", "medium", "low", "none" — see
# https://docs.mistral.ai/capabilities/reasoning/. Override via env var
# ODYSSEUS_MISTRAL_REASONING_EFFORT (e.g. set to "medium" for cheaper chat).
_MISTRAL_REASONING_EFFORT = os.getenv("ODYSSEUS_MISTRAL_REASONING_EFFORT", "high")
# Models that support structured thinking — may output </think> without opening tag
_THINKING_MODEL_PATTERNS = (
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "minimax",
"m2-reap", "gemma", "stepfun", "step-3", "step3",
"magistral", "mistral-small", "mistral-medium",
)
def _supports_thinking(model: str) -> bool:
@@ -919,6 +987,38 @@ def _supports_thinking(model: str) -> bool:
m = model.lower()
return any(p in m for p in _THINKING_MODEL_PATTERNS)
def _normalize_mistral_content(content):
"""Mistral returns content as a structured array when reasoning is on:
[{"type": "thinking", "thinking": [{"type": "text", "text": "..."}], "closed": true},
{"type": "text", "text": "...final answer..."}]
Convert to (text, thinking) tuple of plain strings. Pass through strings
unchanged so non-Mistral OpenAI-compat endpoints are unaffected.
"""
if isinstance(content, str):
return content, ""
if not isinstance(content, list):
return "", ""
text_parts = []
thinking_parts = []
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "text":
t = block.get("text", "")
if t:
text_parts.append(t)
elif btype == "thinking":
inner = block.get("thinking", [])
if isinstance(inner, list):
for tb in inner:
if isinstance(tb, dict) and tb.get("text"):
thinking_parts.append(tb["text"])
elif isinstance(inner, str):
thinking_parts.append(inner)
return "".join(text_parts), "".join(thinking_parts)
def _convert_openai_content_to_anthropic(content):
"""Convert OpenAI multimodal content blocks to Anthropic format.
@@ -1441,6 +1541,8 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
if max_tokens and max_tokens > 0:
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
payload[tok_key] = max_tokens
if provider == "mistral" and _supports_thinking(model):
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
try:
note_model_activity(target_url, model)
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
@@ -1456,7 +1558,16 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
response = _parse_ollama_response(data)
else:
msg = data["choices"][0]["message"]
response = msg.get("content") or msg.get("reasoning_content") or ""
content = msg.get("content")
if isinstance(content, list):
# Mistral structured content — extract thinking + text
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part:
response = thinking_part + "\n\n" + (text_part or "")
else:
response = text_part or msg.get("reasoning_content") or ""
else:
response = content or msg.get("reasoning_content") or ""
_set_cached_response(cache_key, response)
return response
except Exception:
@@ -1638,6 +1749,8 @@ async def llm_call_async(
# Suppress thinking for qwen3/gemma4 on Ollama /v1 — same as stream_llm.
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
payload["think"] = False
if provider == "mistral" and _supports_thinking(model):
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
_apply_local_cache_affinity(payload, url, session_id)
if _is_host_dead(target_url):
@@ -1756,6 +1869,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
payload[tok_key] = max_tokens
if tools:
payload["tools"] = tools
# Mistral thinking-capable models — send reasoning_effort so Mistral
# activates thinking mode and returns structured reasoning_content.
# Effort level is configurable via ODYSSEUS_MISTRAL_REASONING_EFFORT
# (high / medium / low / none); default "high".
if provider == "mistral" and _supports_thinking(model):
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
# For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3,
# gemma4, etc.), suppress thinking so tool calls aren't swallowed inside
# <think> blocks. Ollama /v1 accepts "think": false as a top-level param.
@@ -2134,9 +2253,17 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
# Text content
# Reasoning tokens (VLLM --reasoning-parser, e.g. Qwen3/DeepSeek-R1, Nemotron). vLLM 0.20.2 / NIM emit the field as `reasoning`; older builds use `reasoning_content`. Some OpenAI-compatible Ollama builds use `thinking`.
reasoning = delta.get("reasoning_content") or delta.get("reasoning") or delta.get("thinking") or ""
content = delta.get("content") or ""
# Mistral structured content: content is a list of typed blocks
# ({"type": "thinking", ...}, {"type": "text", ...}). Split into
# reasoning + text so thinking streams into the thinking panel.
if isinstance(content, list):
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part:
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
content = text_part
if reasoning:
yield _stream_delta_event(reasoning, thinking=True)
content = delta.get("content") or ""
if content:
content = re.sub(r"<mm:think(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
+2 -2
View File
@@ -1667,7 +1667,7 @@ class TaskScheduler:
msg["X-Odysseus-Ref"] = str(task.id)
msg.set_content(result or "")
_send_smtp_message(cfg, from_addr, [to_addr], msg.as_string(), timeout=30)
logger.info("Task %s emailed result to %s (%sb)", task.id, to_addr, len(result or ""))
logger.info("Task %s emailed result (recipient_set=%s, %sb)", task.id, bool(to_addr), len(result or ""))
except Exception as e:
logger.error("Task %s email delivery failed: %s", task.id, e, exc_info=True)
raise
@@ -2029,7 +2029,7 @@ class TaskScheduler:
# silent SMTP failure is easier to spot in the logs.
logger.info(
f"Task {task.id} delivered via MCP tool {tool_name} "
f"(to={recipient or '<unset>'}, body={body_len}b, reply={stdout[:200]!r})"
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
)
except Exception as e:
logger.error(f"Task {task.id} MCP delivery failed: {e}")
+1 -1
View File
@@ -103,7 +103,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
"send_to_session": "Send a message to another chat. Cross-chat communication.",
"search_chats": "Search past session transcripts across chats.",
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Omit `multi`/keep it false unless the question explicitly permits choosing multiple options. Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
+8 -2
View File
@@ -467,7 +467,7 @@ FUNCTION_TOOL_SCHEMAS = [
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
"options": {
"type": "array",
"description": "2-6 mutually exclusive choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
"description": "2-6 choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
"items": {
"type": "object",
"properties": {
@@ -477,7 +477,7 @@ FUNCTION_TOOL_SCHEMAS = [
"required": ["label"]
}
},
"multi": {"type": "boolean", "description": "Set true to let the user select multiple options instead of one. Default false."}
"multi": {"type": "boolean", "description": "Set true ONLY when the question explicitly allows choosing more than one option. Otherwise omit it or set false. Default false."}
},
"required": ["question", "options"]
}
@@ -1406,6 +1406,12 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
content = json.dumps(args)
elif tool_type == "ask_teacher":
content = args.get("model", "auto") + "\n" + args.get("problem", "")
elif tool_type == "ask_user":
# Keep user-facing labels readable in the tool trace. The outer SSE
# JSON encoder will escape them for transport and JSON.parse restores
# them once; pre-escaping here caused literal ``\u00f1`` sequences to
# remain visible in the debug panel.
content = json.dumps(args, ensure_ascii=False)
else:
content = json.dumps(args)
Binary file not shown.
Binary file not shown.
+16 -2
View File
@@ -76,7 +76,7 @@
}
// Apply font early
if (t && t.font) {
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif"};
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif",opendyslexic:"'OpenDyslexic', sans-serif"};
if (fm[t.font]) { s.setProperty('--font-family', fm[t.font]); }
else { s.setProperty('--font-family', "'" + t.font.replace(/'/g,'') + "', sans-serif"); }
}
@@ -84,6 +84,12 @@
if (t && t.density && t.density !== 'comfortable') {
document.documentElement.classList.add('density-' + t.density);
}
// Apply UI text-size scale early (global accessibility pref, independent
// of the active theme) so there's no flash on load.
try {
var _us = localStorage.getItem('odysseus-ui-scale');
if (_us && _us !== '100') document.documentElement.classList.add('ui-scale-' + _us);
} catch(e){}
// Apply background pattern on body once available
if (t && t.bgPattern && t.bgPattern !== 'none') {
document.addEventListener('DOMContentLoaded', function() {
@@ -581,6 +587,7 @@
<option value="mono">Monospace</option>
<option value="sans">Sans-serif</option>
<option value="serif">Serif</option>
<option value="opendyslexic">OpenDyslexic (dyslexia-friendly)</option>
</select>
</div>
<div class="theme-fd-group">
@@ -591,6 +598,13 @@
<option value="spacious">Spacious</option>
</select>
</div>
<div class="theme-fd-group">
<label class="theme-fd-label">Text size</label>
<select id="theme-text-size-select" class="theme-fd-select" aria-label="Text size">
<option value="100">Default</option>
<option value="125">Larger</option>
</select>
</div>
<div class="theme-fd-group" id="theme-frosted-group">
<label class="theme-fd-label" for="theme-frosted-toggle">Frosted</label>
<label class="admin-switch" style="margin-top:4px;">
@@ -1318,7 +1332,7 @@
<!-- Cookbook Modal -->
<div id="cookbook-modal" class="modal hidden">
<div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); height: 94vh; max-height: 94vh; background: var(--bg);">
<div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); background: var(--bg);">
<div class="modal-header">
<h4 style="margin:0;margin-right:auto"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>Cookbook</h4>
<button class="close-btn" id="close-cookbook-modal" aria-label="Close cookbook"></button>
+10 -9
View File
@@ -5,6 +5,7 @@
import uiModule from './ui.js';
import spinnerModule from './spinner.js';
import * as Modals from './modalManager.js';
import { topPortalZ } from './toolWindowZOrder.js';
import { makeWindowDraggable } from './windowDrag.js';
import { attachColorPicker } from './colorPicker.js';
import { bindMenuDismiss } from './escMenuStack.js';
@@ -12,7 +13,7 @@ import {
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
_trashIcon, _moreIcon, _bellIcon,
_isCalBgImage, _calBgImageUrl, _calBgCss,
_isCalBgImage, _calBgImageUrl, _calBgCss, _cssUrlEscape,
_calReadableTextColor,
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
} from './calendar/utils.js';
@@ -413,8 +414,8 @@ function _calEventFg(ev) {
// Returns '' for normal solid-color events.
function _calItemBgStyle(ev) {
if (!_isCalBgImage(ev.color)) return '';
const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${url}'); background-size: cover; background-position: center;`;
const url = _calBgImageUrl(ev.color);
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${_cssUrlEscape(url)}'); background-size: cover; background-position: center;`;
}
function _todayCount() {
@@ -470,7 +471,7 @@ function _showEventMoreMenu(ev, anchor) {
dropdown.className = 'cal-event-dropdown';
let closeMenu = () => dropdown.remove();
const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;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;left:0px;visibility:hidden;`;
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;left:0px;visibility:hidden;`;
const _item = (icon, label, onClick, danger) => {
const it = document.createElement('div');
@@ -1260,8 +1261,8 @@ async function _renderWeek() {
// events keep the original tinted treatment.
let bgDecl;
if (_isCalBgImage(ev.color)) {
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_url}'); background-size: cover; background-position: center;`;
const _url = _calBgImageUrl(ev.color);
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_cssUrlEscape(_url)}'); background-size: cover; background-position: center;`;
} else {
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
}
@@ -2853,7 +2854,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
let bg;
if (isCustom) {
const url = _calBgImageUrl(cur);
bg = url ? `center/cover no-repeat url('${url}')` : _CAL_CUSTOM_GRADIENT;
bg = url ? `center/cover no-repeat url('${_cssUrlEscape(url)}')` : _CAL_CUSTOM_GRADIENT;
} else {
bg = c.hex || 'var(--border)';
}
@@ -2928,7 +2929,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
// stays readable. Chrome accent falls back to the theme accent.
const url = _calBgImageUrl(hex);
_formCard.style.setProperty('--ev-color', 'var(--accent)');
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${url.replace(/'/g, "\\'")}')`;
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${_cssUrlEscape(url)}')`;
_formCard.style.backgroundSize = 'cover';
_formCard.style.backgroundPosition = 'center';
_formCard.classList.add('cal-form-bg-image');
@@ -2950,7 +2951,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
if (!url) return;
const sentinel = 'bg:' + url;
dot.dataset.color = sentinel;
dot.style.background = `center/cover no-repeat url('${url}')`;
dot.style.background = `center/cover no-repeat url('${_cssUrlEscape(url)}')`;
document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active'));
dot.classList.add('active');
_applyFormTint(sentinel);
+13 -1
View File
@@ -65,13 +65,25 @@ export function _calBgImageUrl(c) {
return _isCalBgImage(c) ? c.slice(3) : '';
}
// Escape a value for safe embedding inside a single-quoted CSS `url('...')`.
// Backslashes MUST be escaped first: otherwise a trailing/embedded `\` in the
// (CalDAV-syncable, untrusted) bg-image URL would escape the closing quote we
// add for `'` and let the value break out of the string (CodeQL
// js/incomplete-sanitization). `"` is percent-encoded for good measure.
export function _cssUrlEscape(s) {
return String(s == null ? '' : s)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '%22');
}
// Returns a value safe to drop into `style="background:..."`. Falls back to
// the calendar default for bg-image events in spots where an image would be
// too small to render usefully (small grid dots, multi-day bars).
export function _calBgCss(c, fallback) {
if (_isCalBgImage(c)) {
const u = _calBgImageUrl(c);
return u ? `center/cover no-repeat url('${u.replace(/'/g, "\\'")}')` : (fallback || 'var(--accent)');
return u ? `center/cover no-repeat url('${_cssUrlEscape(u)}')` : (fallback || 'var(--accent)');
}
return c || fallback || 'var(--accent)';
}
+13 -142
View File
@@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js';
import chatStream from './chatStream.js';
import { addAITTSButton } from './tts-ai.js';
import markdownModule from './markdown.js';
import { svgifyEmoji } from './markdown.js';
import spinnerModule from './spinner.js';
import presetsModule from './presets.js';
import fileHandlerModule from './fileHandler.js';
@@ -2321,148 +2320,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} else if (json.type === 'ask_user') {
if (_isBg) continue;
// The agent posed a multiple-choice question; the turn has ended.
// Render clickable options at the bottom of the history. The
// user's pick is sent as the next message and the agent resumes.
// Use the shared history renderer so the live and restored
// versions have identical behavior.
_cancelThinkingTimer();
_removeThinkingSpinner();
const _aq = json.data || {};
const _opts = Array.isArray(_aq.options) ? _aq.options : [];
if (_aq.question && _opts.length) {
const chatBox = document.getElementById('chat-history');
// Drop any prior unanswered card so only the latest shows.
chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove());
const card = document.createElement('div');
card.className = 'ask-user-card';
const multi = !!_aq.multi;
// Group the choices for assistive tech and label the group with
// the question (set below); make the card focusable so it can be
// moved to when it appears.
card.setAttribute('role', 'group');
card.tabIndex = -1;
// Render any emoji in agent-supplied text through the app's
// pipeline: escape, then svgify to monochrome theme-tinted
// glyphs (project rule: never colorful emoji; respects the
// "Text-only Emojis" setting like the rest of the chat).
const _emo = (s) => svgifyEmoji(uiModule.esc(String(s)));
// Header row holds the close (×) to dismiss the affordances and
// just type a reply instead.
const head = document.createElement('div');
head.className = 'ask-user-head';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'modal-close ask-user-close';
closeBtn.setAttribute('aria-label', 'Dismiss question');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => {
card.remove();
const mi = uiModule.el('message');
if (mi) mi.focus();
});
head.appendChild(closeBtn);
card.appendChild(head);
// Render the question inside the card so it's self-contained:
// some models call ask_user without first narrating the question
// as assistant text, in which case the card would otherwise show
// bare options with no prompt.
if (_aq.question) {
const q = document.createElement('div');
q.className = 'ask-user-question';
q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
q.innerHTML = _emo(_aq.question);
card.appendChild(q);
// Label the choice group with the question for screen readers.
card.setAttribute('aria-labelledby', q.id);
} else {
card.setAttribute('aria-label', 'Question from the assistant');
}
const list = document.createElement('div');
list.className = 'ask-user-options';
card.appendChild(list);
const _send = (text) => {
if (!text) return;
// Remove the card once answered — the choice is sent as a
// normal user message (and the question persists as the
// assistant text above), so the affordances are spent.
card.remove();
const mi = uiModule.el('message');
if (mi) mi.value = text;
const sb = document.querySelector('.send-btn');
if (sb) sb.click();
};
_opts.forEach((opt, i) => {
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
if (!label) return;
const descr = (opt && opt.description) ? String(opt.description) : '';
const row = document.createElement(multi ? 'label' : 'button');
row.className = 'ask-user-option';
if (multi) {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = label;
row.appendChild(cb);
}
const txt = document.createElement('span');
txt.className = 'ask-user-option-label';
txt.innerHTML = _emo(label);
row.appendChild(txt);
if (descr) {
const d = document.createElement('span');
d.className = 'ask-user-option-desc';
d.innerHTML = _emo(descr);
row.appendChild(d);
}
if (!multi) {
row.type = 'button';
row.addEventListener('click', () => _send(label));
}
list.appendChild(row);
});
// Free-text "Other" — type a custom answer + send (Enter or →).
const other = document.createElement('div');
other.className = 'ask-user-other';
const otherInput = document.createElement('input');
otherInput.type = 'text';
otherInput.className = 'styled-prompt-input ask-user-other-input';
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
const otherSend = document.createElement('button');
otherSend.type = 'button';
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
otherSend.setAttribute('aria-label', 'Send answer');
otherSend.textContent = multi ? 'Send selection' : 'Send';
const _submit = () => {
const free = otherInput.value.trim();
if (multi) {
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value);
if (free) picked.push(free);
if (picked.length) _send(picked.join(', '));
} else if (free) {
_send(free);
}
};
otherSend.addEventListener('click', _submit);
otherInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
_submit();
}
});
other.appendChild(otherInput);
other.appendChild(otherSend);
card.appendChild(other);
chatBox.appendChild(card);
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
// Move focus to the card so keyboard/screen-reader users land on
// the question + choices when it appears.
try { card.focus(); } catch (_) {}
}
chatRenderer.renderAskUserCard(json.data || {});
} else if (json.type === 'plan_update') {
if (_isBg) continue;
@@ -5019,7 +4881,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!header) return;
const node = header.closest('.agent-thread-node');
if (!node) return;
node.classList.toggle('open');
const opened = node.classList.toggle('open');
if (opened) {
// Expanding the final tool trace can push a pending ask_user card below
// the viewport. Keep that immediately-adjacent prompt visible.
const thread = node.closest('.agent-thread');
const pendingCard = thread?.nextElementSibling;
if (pendingCard?.classList.contains('ask-user-card')) {
requestAnimationFrame(() => pendingCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
}
}
});
window.__odysseus_thread_click_bound = true;
}
+152
View File
@@ -3,6 +3,7 @@
import uiModule from './ui.js';
import markdownModule from './markdown.js';
import { svgifyEmoji } from './markdown.js';
import { addAITTSButton } from './tts-ai.js';
import { providerLogo, providerLabel } from './providers.js';
import settingsModule from './settings.js';
@@ -1974,6 +1975,142 @@ export function displayMetrics(messageElement, metrics) {
if (uiModule) uiModule.scrollHistory();
}
/** Remove any unanswered multiple-choice cards currently in the chat. */
export function removeAskUserCards(root) {
const scope = root || document.getElementById('chat-history') || document;
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
}
/**
* Render an ask_user payload as a durable choice card.
*
* This lives in the history renderer rather than the streaming loop so the
* same UI can be used both for a live SSE event and for a persisted tool event
* after a session reload.
*/
export function renderAskUserCard(payload, options) {
const aq = payload || {};
const opts = Array.isArray(aq.options) ? aq.options : [];
const chatBox = document.getElementById('chat-history');
if (!chatBox || !aq.question || opts.length < 2) return null;
const renderOptions = options || {};
removeAskUserCards(chatBox);
const card = document.createElement('div');
card.className = 'ask-user-card';
card.setAttribute('role', 'group');
card.tabIndex = -1;
const multi = !!aq.multi;
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
const head = document.createElement('div');
head.className = 'ask-user-head';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'modal-close ask-user-close';
closeBtn.setAttribute('aria-label', 'Dismiss question');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => {
card.remove();
const input = uiModule.el('message');
if (input) input.focus();
});
head.appendChild(closeBtn);
card.appendChild(head);
const question = document.createElement('div');
question.className = 'ask-user-question';
question.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
question.innerHTML = emojiText(aq.question);
card.appendChild(question);
card.setAttribute('aria-labelledby', question.id);
const list = document.createElement('div');
list.className = 'ask-user-options';
card.appendChild(list);
const send = (text) => {
if (!text) return;
card.remove();
const input = uiModule.el('message');
if (input) input.value = text;
const sendButton = document.querySelector('.send-btn');
if (sendButton) sendButton.click();
};
opts.forEach((opt) => {
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
if (!label) return;
const description = (opt && opt.description) ? String(opt.description) : '';
const row = document.createElement(multi ? 'label' : 'button');
row.className = 'ask-user-option';
if (multi) {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = label;
row.appendChild(checkbox);
}
const labelText = document.createElement('span');
labelText.className = 'ask-user-option-label';
labelText.innerHTML = emojiText(label);
row.appendChild(labelText);
if (description) {
const descriptionText = document.createElement('span');
descriptionText.className = 'ask-user-option-desc';
descriptionText.innerHTML = emojiText(description);
row.appendChild(descriptionText);
}
if (!multi) {
row.type = 'button';
row.addEventListener('click', () => send(label));
}
list.appendChild(row);
});
const other = document.createElement('div');
other.className = 'ask-user-other';
const otherInput = document.createElement('input');
otherInput.type = 'text';
otherInput.className = 'styled-prompt-input ask-user-other-input';
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
const otherSend = document.createElement('button');
otherSend.type = 'button';
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
otherSend.setAttribute('aria-label', 'Send answer');
otherSend.textContent = multi ? 'Send selection' : 'Send';
const submit = () => {
const freeText = otherInput.value.trim();
if (multi) {
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map((input) => input.value);
if (freeText) picked.push(freeText);
if (picked.length) send(picked.join(', '));
} else if (freeText) {
send(freeText);
}
};
otherSend.addEventListener('click', submit);
otherInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit();
}
});
other.appendChild(otherInput);
other.appendChild(otherSend);
card.appendChild(other);
chatBox.appendChild(card);
if (renderOptions.scroll !== false) {
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (renderOptions.focus !== false) {
try { card.focus(); } catch (_) {}
}
return card;
}
/**
* Add a message to the chat history.
*/
@@ -1983,6 +2120,11 @@ export function addMessage(role, content, modelName, metadata) {
const box = document.getElementById('chat-history');
if (!box) { console.error('Chat history element not found'); return; }
// Loading a later user message means any earlier ask_user card was
// answered. This also removes the live card as soon as a manual reply is
// appended, even when the user did not click one of its buttons.
if (role === 'user') removeAskUserCards(box);
var esc = uiModule.esc;
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
@@ -1990,6 +2132,7 @@ export function addMessage(role, content, modelName, metadata) {
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
const roundTexts = metadata.round_texts || [];
const toolEvents = metadata.tool_events;
let pendingAskUser = null;
let lastWrap = null;
let firstMsgAi = null;
let lastMsgAi = null;
@@ -2066,6 +2209,7 @@ export function addMessage(role, content, modelName, metadata) {
box.appendChild(threadWrap);
}
for (const ev of roundTools) {
if (ev.ask_user) pendingAskUser = ev.ask_user;
const ok = (ev.exit_code === 0 || ev.exit_code == null);
let outHtml = '';
if (ev.output && ev.output.trim()) {
@@ -2129,6 +2273,12 @@ export function addMessage(role, content, modelName, metadata) {
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
}
if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
if (pendingAskUser) {
// Session history is rendered oldest-to-newest. A later user message
// removes this card; if there is none, the pending choice survives a
// refresh. Avoid stealing focus while the history is loading.
renderAskUserCard(pendingAskUser, { focus: false, scroll: false });
}
return lastWrap;
}
@@ -2461,6 +2611,8 @@ const chatRenderer = {
copyMessageText,
safeToolScreenshotSrc,
safeDisplayImageSrc,
removeAskUserCards,
renderAskUserCard,
buildSourcesBox,
buildFindingsBox,
appendReportButton,
+5 -4
View File
@@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js';
import themeModule from '../theme.js';
import presetsModule from '../presets.js';
import markdownModule from '../markdown.js';
import { bindMenuDismiss } from '../escMenuStack.js';
var escapeHtml = uiModule.esc;
@@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() {
}
let _exportMenuEl = null;
let _closeExportMenu = () => {};
function _toggleExportMenu(btn) {
if (_exportMenuEl) { _closeExportMenu(); return; }
const r = btn.getBoundingClientRect();
@@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) {
}
document.body.appendChild(m);
_exportMenuEl = m;
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
}
function _closeExportMenu() {
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
_closeExportMenu = bindMenuDismiss(m, () => {
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
}, (ev) => !m.contains(ev.target));
}
async function _exportCopyMarkdown(_btn) {
+9 -11
View File
@@ -33,6 +33,9 @@ import {
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
} from './cookbookServe.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import { topPortalZ } from './toolWindowZOrder.js';
const STORAGE_KEY = 'cookbook-presets';
const LAST_STATE_KEY = 'cookbook-last-state';
const SERVE_STATE_KEY = 'cookbook-serve-state';
@@ -1514,7 +1517,7 @@ async function _fetchDependencies() {
// Wire the installed-package menu.
function _showDepMenu(anchor) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
const row = anchor.closest('.cookbook-dep-row');
if (!row) return;
const pipName = row.dataset.depPip;
@@ -1527,7 +1530,7 @@ async function _fetchDependencies() {
const minW = 150;
let left = Math.min(rect.right - minW, window.innerWidth - minW - 8);
left = Math.max(8, left);
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
const upIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>';
const it = document.createElement('div');
it.className = 'dropdown-item-compact';
@@ -1535,7 +1538,7 @@ async function _fetchDependencies() {
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
it.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
close();
await _installDep(pipName, pkgName, isLocalOnly, true, null);
});
dropdown.appendChild(it);
@@ -1563,19 +1566,14 @@ async function _fetchDependencies() {
dropdown.appendChild(source);
}
document.body.appendChild(dropdown);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
}
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (document.querySelector('.cookbook-dep-menu')) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
return;
}
_showDepMenu(btn);
+3 -2
View File
@@ -11,6 +11,7 @@ import { modelColor } from './chatRenderer.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import { openCookbookDependencies } from './cookbook-diagnosis.js';
import { _hwfitCache } from './cookbook-hwfit.js';
import { topPortalZ } from './toolWindowZOrder.js';
// Shared state/functions injected by init()
let _envState;
@@ -1019,7 +1020,7 @@ function _rerenderCachedModels() {
cancelDiv.addEventListener('click', () => { closeDropdown(); });
dropdown.appendChild(cancelDiv);
const rect = btn.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
document.body.appendChild(dropdown);
// Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ
// on mobile under the dynamic toolbar). Flip above the button if there's no
@@ -2166,7 +2167,7 @@ function _rerenderCachedModels() {
// Cap width/height to the viewport and start hidden — we clamp the final
// position after mount (below) using the menu's real measured size, so it
// can't run off-screen on a narrow mobile viewport.
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:10001;top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:${topPortalZ()};top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
if (!modelSlots.length) {
const empty = document.createElement('div');
+22 -38
View File
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
import signatureModule from './signature.js';
import * as Modals from './modalManager.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
let API_BASE = '';
let isOpen = false;
@@ -666,7 +667,7 @@ import * as Modals from './modalManager.js';
overlay.className = 'modal pdf-export-overlay';
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);';
overlay.innerHTML = `
<div class="modal-content" style="width:min(780px,94vw);max-height:86vh;">
<div class="modal-content" style="width:min(780px,94vw);">
<div class="modal-header">
<h4>Export filled PDF</h4>
<button id="pdf-export-close" class="modal-close" title="Close">×</button>
@@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js';
let _docAiReplyChoiceMenu = null;
function _closeDocAiReplyChoice() {
if (_docAiReplyChoiceMenu) {
try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
// Tear down through the menu's registered dismiss (drops its outside-click
// listener + Escape-stack entry) rather than orphaning them with a raw
// remove(); the onClose below nulls the ref.
try { dismissOrRemove(_docAiReplyChoiceMenu); } catch (_) {}
_docAiReplyChoiceMenu = null;
}
}
@@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js';
const noteInput = menu.querySelector('[data-note-input]');
setTimeout(() => noteInput?.focus(), 0);
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
document.body.appendChild(menu);
_docAiReplyChoiceMenu = menu;
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual teardown (node removal + state).
const close = bindMenuDismiss(menu, () => {
try { menu.remove(); } catch (_) {}
if (_docAiReplyChoiceMenu === menu) _docAiReplyChoiceMenu = null;
});
menu.addEventListener('click', async (ev) => {
const choice = ev.target.closest('[data-mode]');
if (!choice) return;
@@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js';
ev.stopPropagation();
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
const noteHint = (noteInput?.value || '').trim();
_closeDocAiReplyChoice();
close();
await _aiReply({ mode, noteHint });
});
document.body.appendChild(menu);
_docAiReplyChoiceMenu = menu;
const outsideClose = (ev) => {
if (menu.contains(ev.target)) return;
document.removeEventListener('click', outsideClose, true);
_closeDocAiReplyChoice();
};
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
// Esc to close.
const escClose = (ev) => {
if (ev.key === 'Escape') {
ev.stopPropagation();
document.removeEventListener('keydown', escClose, true);
_closeDocAiReplyChoice();
}
};
document.addEventListener('keydown', escClose, true);
}
async function _aiReply(opts = {}) {
@@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js';
function showExportMenu(e, anchorRect) {
if (e) e.stopPropagation();
// Remove existing menu if any
// Remove existing menu if any (toggle off) — tear it down through its
// registered dismiss so the outside-click listener + Escape-stack entry go.
const existing = document.getElementById('doc-export-menu');
if (existing) { existing.remove(); return; }
if (existing) { dismissOrRemove(existing); return; }
// Position from provided rect, clicked element, or fallback to language select
const rect = anchorRect
@@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js';
const item = document.createElement('button');
item.className = 'doc-overflow-item';
item.textContent = opt.label;
item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); });
item.addEventListener('click', (ev) => { ev.stopPropagation(); close(); opt.fn(); });
menu.appendChild(item);
if (opt._divider) {
const sep = document.createElement('div');
@@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js';
menu.style.top = 'auto';
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
}
const close = (ev) => {
if (ev && ev.type === 'keydown') {
if (ev.key !== 'Escape') return;
ev.preventDefault();
ev.stopPropagation();
ev.stopImmediatePropagation?.();
} else if (ev && menu.contains(ev.target)) {
return;
}
menu.remove();
document.removeEventListener('click', close);
document.removeEventListener('keydown', close, true);
};
setTimeout(() => document.addEventListener('click', close), 100);
document.addEventListener('keydown', close, true);
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual node removal.
const close = bindMenuDismiss(menu, () => { menu.remove(); });
}
function exportAsHtml() {
+4 -3
View File
@@ -4,6 +4,7 @@
* Extracted from document.js to reduce file size.
*/
import { topPortalZ } from './toolWindowZOrder.js';
import uiModule from './ui.js';
import sessionModule from './sessions.js';
import spinnerModule from './spinner.js';
@@ -227,7 +228,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
dd.style.right = (window.innerWidth - rect.right) + 'px';
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.display = 'block';
dd.style.zIndex = '100000';
dd.style.zIndex = String(topPortalZ());
requestAnimationFrame(() => {
const mr = dd.getBoundingClientRect();
if (mr.bottom > window.innerHeight - 8) {
@@ -629,7 +630,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const rect = menuBtn.getBoundingClientRect();
document.body.appendChild(dropdown);
dropdown.dataset.owner = doc.id;
dropdown.style.cssText = 'position:fixed;z-index:10000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;';
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;`;
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.left = 'auto';
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
@@ -1595,7 +1596,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
modal.className = 'modal';
modal.id = 'doclib-modal';
modal.innerHTML = `
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);">
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);background:var(--bg);">
<div class="modal-header">
<!-- Header title + icon mirror the currently-active sub-tab (Chats /
Documents / Research / Archive) so the user sees ONE icon at
+6 -11
View File
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
const _acct = () => window.__odysseusActiveEmailAccount
@@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
}
function _showEmailMenu(em, anchor, itemEl) {
document.querySelectorAll('.email-dropdown').forEach(d => d.remove());
document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'dropdown email-dropdown show';
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
_showRemindSubmenu(em, dropdown);
return;
}
dropdown.remove();
close();
a.action();
});
dropdown.appendChild(menuItem);
@@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) {
anchor.appendChild(dropdown);
const close = (e) => {
if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
}
// ---- Reminder submenu (creates a Note with a reminder for this email) ----
@@ -987,7 +982,7 @@ function _showRemindSubmenu(em, parentDropdown) {
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();
dismissOrRemove(parentDropdown);
await _createReplyReminder(em, p.date);
});
parentDropdown.appendChild(item);
@@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) {
customItem.innerHTML = '<span>Pick date and time…</span>';
customItem.addEventListener('click', async (e) => {
e.stopPropagation();
parentDropdown.remove();
dismissOrRemove(parentDropdown);
const tmp = document.createElement('input');
tmp.type = 'datetime-local';
const def = new Date(tomorrow);
+32 -50
View File
@@ -8,6 +8,7 @@ import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
import { folderDisplayName, sortedFolders } from './emailInbox.js';
import settingsModule from './settings.js';
import * as Modals from './modalManager.js';
import { topPortalZ } from './toolWindowZOrder.js';
import { makeWindowDraggable } from './windowDrag.js';
import {
_esc, _escLinkify, _extractName, _parseTurnMeta,
@@ -23,6 +24,7 @@ import {
} from './emailLibrary/signatureFold.js';
import { state } from './emailLibrary/state.js';
import { collapseSidebarToRail } from './modalSnap.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _emailUnreadChipClickWired = false;
@@ -858,7 +860,7 @@ export function openEmailLibrary(opts = {}) {
modal.className = 'modal';
modal.id = 'email-lib-modal';
modal.innerHTML = `
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);max-height:85vh;background:var(--bg);">
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);background:var(--bg);">
<div class="modal-header">
<h4>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;">
@@ -4866,7 +4868,7 @@ async function _openEmailAsTab(em, folder) {
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);max-height:85vh;display:flex;flex-direction:column;">
<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>
@@ -5101,7 +5103,7 @@ async function _openEmailWindow(em, folder) {
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);max-height:80vh;display:flex;flex-direction:column;background:var(--bg);">
<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>
@@ -5499,23 +5501,19 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
// Toggle: if a dropdown for THIS anchor is already open, close it.
const existing = document.querySelector('.email-card-dropdown');
if (existing && existing._anchor === anchor) {
existing.remove();
anchor.classList.remove('reader-more-active');
dismissOrRemove(existing);
return;
}
// Otherwise close any other open dropdown (and clear its anchor's active
// state) before opening a fresh one.
document.querySelectorAll('.email-card-dropdown').forEach(d => {
if (d._anchor) d._anchor.classList.remove('reader-more-active');
d.remove();
});
// 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:10001;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;`;
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>';
@@ -5721,8 +5719,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
_showLibRemindSubmenu(em, dropdown);
return;
}
dropdown.remove();
anchor.classList.remove('reader-more-active');
close();
a.action();
});
dropdown.appendChild(item);
@@ -5735,30 +5732,25 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
cancelItem.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.remove();
anchor.classList.remove('reader-more-active');
close();
});
dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
anchor.classList.remove('reader-more-active');
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
anchor.classList.remove('reader-more-active');
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
function _showCardMenu(em, anchor) {
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown';
const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;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;`;
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>';
@@ -5918,8 +5910,7 @@ function _showCardMenu(em, anchor) {
_showLibRemindSubmenu(em, dropdown);
return;
}
dropdown.remove();
anchor.classList.remove('reader-more-active');
close();
a.action();
});
dropdown.appendChild(item);
@@ -5932,30 +5923,25 @@ function _showCardMenu(em, anchor) {
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
cancelItem.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.remove();
anchor.classList.remove('reader-more-active');
close();
});
dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
anchor.classList.remove('reader-more-active');
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
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.
function _showBulkActionsMenu(anchor) {
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
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:10001;min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
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>';
@@ -5968,7 +5954,7 @@ function _showBulkActionsMenu(anchor) {
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(); dropdown.remove(); a.action(); });
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it);
}
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
@@ -5978,7 +5964,7 @@ function _showBulkActionsMenu(anchor) {
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
cancelIt.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.remove();
close();
// Cancel inside the bulk-Actions menu also exits select mode — matches the
// documents bulk dropdown.
state._selectMode = false;
@@ -5989,13 +5975,9 @@ function _showBulkActionsMenu(anchor) {
dropdown.appendChild(cancelIt);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
function _updateBulkBar() {
@@ -6240,7 +6222,7 @@ function _showAiReplyChoice(btn, em, data) {
`max-height:${window.innerHeight - 16}px`,
'overflow:auto',
'box-sizing:border-box',
'z-index:10060',
`z-index:${topPortalZ()}`,
'display:flex',
'gap:6px',
'padding:6px',
+3 -1
View File
@@ -8,6 +8,8 @@
* faces (😂, 👍, 😎) have no text form and are intentionally excluded.
*/
import { topPortalZ } from './toolWindowZOrder.js';
// Each entry: [char, label, svgPath OR svg]
// SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
@@ -158,7 +160,7 @@ function togglePicker(anchor, target) {
_pickerEl.style.position = 'fixed';
_pickerEl.style.top = (rect.bottom + 4) + 'px';
_pickerEl.style.left = rect.left + 'px';
_pickerEl.style.zIndex = '10000';
_pickerEl.style.zIndex = String(topPortalZ());
requestAnimationFrame(() => {
const pr = _pickerEl.getBoundingClientRect();
+7 -11
View File
@@ -6,6 +6,8 @@ import uiModule from './ui.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import { topPortalZ } from './toolWindowZOrder.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -2514,7 +2516,7 @@ export function openGallery() {
// shares the exact same dropdown style/behaviour.
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
function _showGalleryBulkMenu(anchor) {
document.querySelectorAll('.gallery-bulk-menu').forEach(d => d.remove());
document.querySelectorAll('.gallery-bulk-menu').forEach(dismissOrRemove);
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
// matches every other menu in the app. Positioned fixed at the button.
const dropdown = document.createElement('div');
@@ -2523,7 +2525,7 @@ export function openGallery() {
const left = Math.min(rect.left, window.innerWidth - 200);
// Inline the standard dropdown look so it renders correctly even where the
// `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile).
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
const _favIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>';
const _tagIco = '<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="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>';
const _dlIco = '<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>';
@@ -2548,17 +2550,11 @@ export function openGallery() {
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(); dropdown.remove(); a.action(); });
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it);
}
document.body.appendChild(dropdown);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
_bulkActionsBtn?.addEventListener('click', (e) => {
@@ -2567,7 +2563,7 @@ export function openGallery() {
// should close it. The outside-click handler explicitly skips clicks on
// the anchor, so the button itself has to do its own dismiss.
const existing = document.querySelector('.gallery-bulk-menu');
if (existing) { existing.remove(); return; }
if (existing) { dismissOrRemove(existing); return; }
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
_showGalleryBulkMenu(e.currentTarget);
});
+22 -6
View File
@@ -483,6 +483,7 @@ export function processWithThinking(text) {
export function mdToHtml(src, opts) {
const allowedHtmlBlocks = [];
const codeBlocks = [];
const inlineCodeBlocks = [];
const mermaidBlocks = [];
let s = (src ?? '');
@@ -521,6 +522,19 @@ export function mdToHtml(src, opts) {
return placeholder;
});
// Extract inline code spans before the link/autolink/HTML passes, mirroring
// the fenced-block handling above. A URL inside `inline code` (e.g.
// `irm http://127.0.0.1:3000/x`) is preceded by a space, so the bare-URL
// autolink matches it, wraps it in an <a> tag, and swaps that for an
// ___ALLOWED_HTML_ placeholder — corrupting the command. The old inline-code
// pass ran after those passes, too late to protect it.
s = s.replace(/`([^`]+?)`/g, (match, code) => {
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___MERMAID_BLOCK_')) return match;
const placeholder = `___INLINE_CODE_${inlineCodeBlocks.length}___`;
inlineCodeBlocks.push(`<code>${escapeHtml(code)}</code>`);
return placeholder;
});
// Repair common ways the agent mangles the entity-anchor convention
// (`[Name](#kind-<id>)`). Models reliably get the single-link case
// right but slip into other formats when listing many in a table.
@@ -678,12 +692,6 @@ export function mdToHtml(src, opts) {
return html;
});
// Inline code (but not placeholders)
s = s.replace(/`([^`]+?)`/g, (match, code) => {
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___ALLOWED_HTML_')) return match;
return `<code>${code}</code>`;
});
// Horizontal rules (must come before bold/italic to avoid * conflicts)
s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr>');
@@ -756,6 +764,14 @@ export function mdToHtml(src, opts) {
s = s.replace(`___CODE_BLOCK_${index}___`, block);
});
// Restore inline code spans last, so placeholders carried inside restored
// <a>/allowed-HTML blocks are resolved too. The function replacer keeps the
// escaped code literal — e.g. a shell snippet like `echo $1` is not treated
// as a regex back-reference.
inlineCodeBlocks.forEach((block, index) => {
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
});
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
}
+8 -1
View File
@@ -6,6 +6,7 @@ import sessionModule from './sessions.js';
import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js';
import { topPortalZ } from './toolWindowZOrder.js';
var escapeHtml = uiModule.esc;
@@ -865,7 +866,13 @@ export function renderMemoryList() {
dropdown.style.top = rect.bottom + 2 + 'px';
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
dropdown.style.left = 'auto';
dropdown.style.zIndex = '10001';
// Portaled to <body>, so it must outrank the Brain modal it belongs to.
// Tool modals get a monotonically increasing z-index from modalManager's
// bring-to-front counter, which climbs unbounded over a long session —
// once it passed the old hardcoded 10001 the menu rendered behind the
// panel (#4720). topPortalZ() derives the value from the live tool-window
// stack so the menu always sits just above, however high it has climbed.
dropdown.style.zIndex = String(topPortalZ());
dropdown.style.display = 'block';
document.body.appendChild(dropdown);
// Keep on-screen (mobile): flip above the button if it overflows the
+11 -18
View File
@@ -10,7 +10,8 @@ import { attachColorPicker } from './colorPicker.js';
import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js';
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
import { topToolWindowZ } from './toolWindowZOrder.js';
import { topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -3360,7 +3361,7 @@ function _buildForm(note = null) {
function _pickCustomDate() {
// Replace the dropdown menu with a small inline picker
document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove());
document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove);
const menu = document.createElement('div');
menu.className = 'note-reminder-menu';
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
@@ -3394,14 +3395,11 @@ function _buildForm(note = null) {
if (typeof dInput.showPicker === 'function') {
try { dInput.showPicker(); } catch {}
}
const close = bindMenuDismiss(menu, () => { menu.remove(); });
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
if (dInput.value) _setReminder(dInput.value);
menu.remove();
close();
});
setTimeout(() => {
const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } };
document.addEventListener('click', close);
}, 0);
}
if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
@@ -4311,7 +4309,7 @@ function _serializeNoteForCopy(note) {
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
function _openNoteCornerMenu(btn) {
document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove());
document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove);
const id = btn.dataset.noteId;
const note = _notes.find(n => n.id === id);
if (!note) return;
@@ -4337,15 +4335,10 @@ function _openNoteCornerMenu(btn) {
const mh = menu.offsetHeight || 96;
const below = window.innerHeight - r.bottom;
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`;
const close = (ev) => {
if (ev && menu.contains(ev.target)) return;
menu.remove();
document.removeEventListener('click', close, true);
};
setTimeout(() => document.addEventListener('click', close, true), 0);
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { menu.remove(); _copyNote(id, btn); });
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); });
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`;
const close = bindMenuDismiss(menu, () => { menu.remove(); });
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
}
function _positionNoteMenu(menu, btn, width = 196) {
@@ -4356,7 +4349,7 @@ function _positionNoteMenu(menu, btn, width = 196) {
const mh = menu.offsetHeight || 112;
const below = window.innerHeight - r.bottom;
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
const close = (ev) => {
if (ev && menu.contains(ev.target)) return;
menu.remove();
+1
View File
@@ -1938,6 +1938,7 @@ async function _onSessionListKeydown(e) {
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (item.querySelector('.session-rename-input')) return;
e.preventDefault();
const sid = item.dataset.sessionId;
const s = sessions.find(x => x.id === sid);
+16 -9
View File
@@ -8,6 +8,7 @@ import { clearDockSide } from './modalSnap.js';
import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
import { bindMenuDismiss } from './escMenuStack.js';
let initialized = false;
let modalEl = null;
@@ -3838,7 +3839,10 @@ async function initUnifiedIntegrations() {
if (lbl) lbl.textContent = text;
if (ico) ico.innerHTML = _apiIconFor(k);
};
const _close = () => { menu.style.display = 'none'; };
// Menu is reused (hidden, not recreated). close() hides it and tears down
// its outside-click listener + Escape-stack entry; bindMenuDismiss is
// re-registered fresh on each open (see _open).
let _close = () => { menu.style.display = 'none'; };
const _open = () => {
menu.style.display = 'block';
const tRect = trig.getBoundingClientRect();
@@ -3847,8 +3851,7 @@ async function initUnifiedIntegrations() {
const above = tRect.top;
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } };
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
_close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
};
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
menu.querySelectorAll('.ufapi-option').forEach(btn => {
@@ -4584,7 +4587,10 @@ async function initUnifiedIntegrations() {
if (labelEl) labelEl.textContent = lbl;
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
};
const _closeMenu = () => { menu.style.display = 'none'; };
// Menu is reused (hidden, not recreated). _closeMenu hides it and tears
// down its outside-click listener + Escape-stack entry; bindMenuDismiss is
// re-registered fresh on each open (see _openMenu).
let _closeMenu = () => { menu.style.display = 'none'; };
const _openMenu = () => {
menu.style.display = 'block';
// Drop-up when there's not enough room below the trigger.
@@ -4597,8 +4603,7 @@ async function initUnifiedIntegrations() {
} else {
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
}
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
_closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
};
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
menu.querySelectorAll('.ufp-option').forEach(btn => {
@@ -5650,8 +5655,11 @@ async function initUnifiedIntegrations() {
addBtn.parentElement.style.position = 'relative';
addBtn.parentElement.classList.add('uf-add-anchor');
}
// Menu is created per open and removed on close. _closeMenu routes through
// the bindMenuDismiss close() bound when the menu opens, so the outside-click
// listener + Escape-stack entry are torn down alongside the node removal.
let _menuEl = null;
const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } };
let _closeMenu = () => {};
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (_menuEl) { _closeMenu(); return; }
@@ -5683,8 +5691,7 @@ async function initUnifiedIntegrations() {
showForm(k, 'new');
});
});
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
_closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
});
}
+7 -7
View File
@@ -7,6 +7,7 @@
import uiModule from './ui.js';
import * as spinnerModule from './spinner.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API = window.location.origin;
let skills = [];
@@ -391,14 +392,14 @@ function _svg(paths, { fill = 'none', size = 13 } = {}) {
// Kebab dropdown for a collapsed skill card — same actions + icons as the
// expanded footer (Publish/Unpublish · Edit · Delete).
function _openSkillMenu(btn, card, sk, name, isPublished) {
document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove());
document.querySelectorAll('.skill-kebab-menu').forEach(dismissOrRemove);
const menu = document.createElement('div');
menu.className = 'skill-kebab-menu';
const mk = (paths, label, opts, onClick) => {
const item = document.createElement('button');
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); });
item.addEventListener('click', (e) => { e.stopPropagation(); close(); onClick(); });
menu.appendChild(item);
};
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
@@ -410,7 +411,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
selItem.innerHTML = '<svg class="memory-select-btn-icon" 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;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>';
selItem.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
close();
if (!_selectMode) _enterSelectMode();
_selectedNames.add(name);
renderSkillsList();
@@ -432,7 +433,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
const cancelItem = document.createElement('button');
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
cancelItem.innerHTML = '<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><span>Cancel</span>';
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); });
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); });
menu.appendChild(cancelItem);
document.body.appendChild(menu);
@@ -453,8 +454,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
menu.style.overflowY = 'auto';
}
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
setTimeout(() => document.addEventListener('click', close, true), 0);
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
}
// Cards for the agent's built-in tool capabilities (from
@@ -1802,7 +1802,7 @@ async function _showSkillSource(name) {
wrap.className = 'modal';
wrap.style.display = 'block';
wrap.innerHTML = `
<div class="modal-content" style="max-width:760px;max-height:85vh;display:flex;flex-direction:column">
<div class="modal-content" style="max-width:760px;display:flex;flex-direction:column">
<div class="modal-header">
<h4>SKILL.md <code>${esc(name)}</code></h4>
<span style="flex:1"></span>
+4
View File
@@ -101,6 +101,8 @@ function _setupProviderFromInput(input) {
xai: 'xai',
grok: 'xai',
nvidia: 'nvidia',
opencodezen: 'opencode-zen',
opencodego: 'opencode-go',
};
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
}
@@ -129,6 +131,8 @@ function _extractSetupProviderCredential(input) {
['google', 'gemini'], ['gemini', 'gemini'],
['x ai', 'xai'], ['xai', 'xai'], ['grok', 'xai'],
['nvidia', 'nvidia'],
['opencode zen', 'opencode-zen'], ['opencode-zen', 'opencode-zen'],
['opencode go', 'opencode-go'], ['opencode-go', 'opencode-go'],
];
for (const [alias, key] of providerAliases) {
const re = new RegExp('(^|\\s|[,;:])(' + alias.replace(/\s+/g, '\\s+') + ')(?=$|\\s|[,;:])', 'i');
+1
View File
@@ -24,6 +24,7 @@ export const KEYS = {
SECTION_ORDER: 'sidebar-section-order',
ADMIN_LAST_TAB: 'admin-last-tab',
DENSITY: 'odysseus-density',
UI_SCALE: 'odysseus-ui-scale',
WORKSPACE: 'odysseus-workspace'
};
+8 -10
View File
@@ -8,6 +8,7 @@ import * as spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -899,7 +900,7 @@ function _attachTaskLongPress(card, menuBtn) {
function _showTaskDropdown(anchor, items) {
// Remove any existing dropdown
document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove);
const dd = document.createElement('div');
dd.className = 'task-dropdown';
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
@@ -914,7 +915,7 @@ function _showTaskDropdown(anchor, items) {
}
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
btn.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); });
btn.addEventListener('click', (e) => { e.stopPropagation(); close(); item.action(); });
dd.appendChild(btn);
});
document.body.appendChild(dd);
@@ -926,16 +927,13 @@ function _showTaskDropdown(anchor, items) {
dd.style.top = top + 'px';
dd.style.left = left + 'px';
const openedAt = performance.now();
const close = (e) => {
const close = bindMenuDismiss(dd, () => { dd.remove(); }, (ev) => {
// Ignore any clicks that occur within 250ms of the open (covers touch
// "ghost click" duplicates that were firing right after pointerup and
// removing the dropdown before the user could see it).
if (performance.now() - openedAt < 250) return;
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
};
// requestAnimationFrame so the listener is registered AFTER the current
// pointer/click event cycle has finished bubbling.
requestAnimationFrame(() => document.addEventListener('click', close));
// removing the dropdown before the user could see it) — treat as inside.
if (performance.now() - openedAt < 250) return false;
return !dd.contains(ev.target);
});
}
// ---- Presets ----
+27
View File
@@ -39,6 +39,7 @@ const FONT_MAP = {
mono: "'Fira Code', monospace",
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
serif: "Georgia, 'Times New Roman', serif",
opendyslexic: "'OpenDyslexic', sans-serif",
};
const DEFAULT_FONT = 'mono';
const DEFAULT_DENSITY = 'comfortable';
@@ -387,6 +388,20 @@ export function applyFontDensity(font, density) {
if (d !== 'comfortable') document.documentElement.classList.add('density-' + d);
}
// UI text-size scale (accessibility). Global and independent of the active
// theme, so the chosen size persists across theme switches. Stored as a plain
// percentage string ('100' | '110' | '125' | '150').
const UI_SCALE_KEY = 'odysseus-ui-scale';
const DEFAULT_UI_SCALE = '100';
export function applyUiScale(scale) {
const s = scale || DEFAULT_UI_SCALE;
// Only one non-default scale ('125'). Remove any legacy classes too so an
// older stored value can't leave a stale zoom applied.
document.documentElement.classList.remove('ui-scale-110', 'ui-scale-125', 'ui-scale-140');
if (s === '125') document.documentElement.classList.add('ui-scale-125');
}
const _BG_CLASSES = ['bg-pattern-dots',
'bg-pattern-synapse', 'bg-pattern-rain', 'bg-pattern-constellations',
'bg-pattern-perlin-flow',
@@ -1133,6 +1148,18 @@ export function initThemeUI() {
const s = getSaved(); if (s) _saveFull(s.name, s.colors);
});
}
const textSizeSelect = document.getElementById('theme-text-size-select');
if (textSizeSelect) {
const nts = textSizeSelect.cloneNode(true); textSizeSelect.parentNode.replaceChild(nts, textSizeSelect);
let initScale = DEFAULT_UI_SCALE;
try { initScale = localStorage.getItem(UI_SCALE_KEY) || DEFAULT_UI_SCALE; } catch (e) {}
nts.value = initScale;
applyUiScale(initScale);
nts.addEventListener('change', () => {
applyUiScale(nts.value);
try { localStorage.setItem(UI_SCALE_KEY, nts.value); } catch (e) {}
});
}
if (patternSelect) {
const np = patternSelect.cloneNode(true); patternSelect.parentNode.replaceChild(np, patternSelect);
np.value = _initPattern;
+17
View File
@@ -27,3 +27,20 @@ export function nextToolWindowZ(options = {}) {
if (Number.isFinite(currentZ) && currentZ > top) return currentZ;
return top + 1;
}
// Dock chips pinned by the minimized-dock drag interactions reach z 10030
// (free-drag) / 10020 (mobile rest) — see modalManager.js. A body-portaled
// dropdown has to clear those too, not just the open tool-window stack, so this
// floor keeps it above a chip even when no modal is currently raised.
const DOCK_OVERLAY_FLOOR = 10030;
// The z a body-portaled dropdown/menu needs so it always sits just above every
// open tool window (and the dock chips) right now. Tool modals get a
// monotonically increasing z from the bring-to-front counter (modalManager),
// which climbs unbounded over a long session — so the hardcoded `z-index: 10001`
// these dropdowns historically used eventually rendered them BEHIND their own
// modal (#4720). Derive the value from the live stack instead, sharing the same
// single source of truth as nextToolWindowZ().
export function topPortalZ(options = {}) {
return Math.max(topToolWindowZ(options), DOCK_OVERLAY_FLOOR) + 1;
}
+37
View File
@@ -114,6 +114,10 @@ body {
@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'); }
/* 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: 0.95em;
@@ -158,6 +162,39 @@ html {
:root.density-spacious .list-item { padding: 8px 12px; }
:root.density-spacious .sidebar .section { padding: 0; }
/* UI text-size scale (accessibility)
Density only changes the root font-size, which can't move the many
hard-coded px sizes. `zoom` scales the whole UI uniformly (px text
included) while keeping layout intact, unlike `transform: scale`. */
:root.ui-scale-125 { zoom: 1.25; }
/* `zoom` makes the 100dvh shell render taller than the real viewport, which
pushes the bottom-pinned sidebar account/settings row below the fold (and
body's overflow:hidden then clips it). Shrink the shell by the same factor
so it fits the viewport exactly. */
:root.ui-scale-125 body { height: calc(100dvh / 1.25); }
/* Modals/panels under the 1.25x scale: zoom renders a centred, viewport-sized
panel ~1.25x taller, pushing its draggable header + close button off-screen
(a catch-22 you can't reach the control to turn the size back down). Divide
each max-height by the same factor to keep the original on-screen footprint.
Desktop only the mobile `!important` full-sheet rules win on small screens
and stay top-anchored, so their headers are already visible. */
:root.ui-scale-125 .modal-content { max-height: calc(85dvh / 1.25); }
:root.ui-scale-125 .cal-modal-content { max-height: calc(88dvh / 1.25); }
:root.ui-scale-125 .settings-modal-content { max-height: calc(85dvh / 1.25); }
:root.ui-scale-125 #theme-popup { max-height: min(calc(85dvh / 1.25), 480px); }
/* Cookbook is the one modal that set its height inline (94vh), which beat the
.modal-content compensation above and overflowed the viewport at 1.25x
(header + close button pushed off-screen). Own its height here so the same
zoom compensation applies. */
#cookbook-modal .modal-content { height: 94vh; max-height: 94vh; }
:root.ui-scale-125 #cookbook-modal .modal-content { height: calc(94dvh / 1.25); max-height: calc(94dvh / 1.25); }
/* PDF export modal also set its height inline (86vh) at v1.0; that inline cap
beat the .modal-content compensation above and shifted ~1vh at Default when
removed. Own its height here so Default is byte-for-byte 86vh and the same
1.25x compensation applies. */
.pdf-export-overlay .modal-content { max-height: 86vh; }
:root.ui-scale-125 .pdf-export-overlay .modal-content { max-height: calc(86dvh / 1.25); }
/* ── Background Patterns ── */
:root { --bg-effect-intensity: 1; }
+97
View File
@@ -0,0 +1,97 @@
"""Regression coverage for durable ``ask_user`` choice cards.
The live event must arrive after ``tool_output`` so the settled tool trace
cannot cover/push away the card. The same payload must be persisted inside
``tool_events`` so chat history can reconstruct it after a reload.
"""
import asyncio
import json
from pathlib import Path
import src.agent_loop as agent_loop
ROOT = Path(__file__).resolve().parents[1]
def _collect(gen):
async def _run():
return [chunk async for chunk in gen]
return asyncio.run(_run())
def _events(chunks):
events = []
for chunk in chunks:
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
events.append(json.loads(chunk[6:]))
return events
def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
payload = {
"question": "¿Qué proyecto prefieres?",
"options": [
{"label": "Análisis de reseñas"},
{"label": "Clasificación temática"},
],
"multi": False,
}
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default, raising=False)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10, raising=False)
async def fake_stream(_candidates, messages, **kwargs):
call = {"name": "ask_user", "arguments": json.dumps(payload, ensure_ascii=False)}
yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
yield "data: [DONE]\n\n"
async def fake_execute(block, *args, **kwargs):
parsed = json.loads(block.content)
return (
"ask_user",
{
"ask_user": parsed,
"output": "Awaiting their selection.",
"exit_code": 0,
},
)
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream, raising=False)
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute, raising=False)
chunks = _collect(
agent_loop.stream_agent_loop(
"https://api.openai.com/v1",
"gpt-4o",
[{"role": "user", "content": "Ayúdame a elegir un proyecto."}],
relevant_tools={"ask_user"},
_is_teacher_run=True,
)
)
events = _events(chunks)
tool_output_index = next(i for i, event in enumerate(events) if event.get("type") == "tool_output")
ask_user_index = next(i for i, event in enumerate(events) if event.get("type") == "ask_user")
assert tool_output_index < ask_user_index
tool_output = events[tool_output_index]
assert tool_output["ask_user"] == payload
assert "¿Qué proyecto prefieres?" in tool_output["command"]
assert "\\u00" not in tool_output["command"]
metrics = next(event["data"] for event in events if event.get("type") == "metrics")
assert metrics["tool_events"][0]["ask_user"] == payload
def test_frontend_uses_one_renderer_for_live_and_restored_cards():
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
assert "chatRenderer.renderAskUserCard(json.data || {})" in chat
assert "export function renderAskUserCard" in renderer
assert "renderAskUserCard(pendingAskUser" in renderer
assert "if (role === 'user') removeAskUserCards(box)" in renderer
+13
View File
@@ -85,6 +85,19 @@ def test_serializer_round_trips_structured_args():
assert json.loads(block.content) == args
def test_serializer_keeps_unicode_readable_for_tool_trace():
from src.tool_schemas import function_call_to_tool_block
args = {
"question": "¿Qué proyecto prefieres?",
"options": [{"label": "Reseñas"}, {"label": "Clasificación"}],
}
block = function_call_to_tool_block("ask_user", json.dumps(args, ensure_ascii=False))
assert "¿Qué proyecto prefieres?" in block.content
assert "Reseñas" in block.content
assert "\\u00" not in block.content
def test_registered_everywhere():
# TOOL_TAGS gate (serializer rejects unknown tools)
assert "ask_user" in TOOL_TAGS
+280
View File
@@ -0,0 +1,280 @@
"""Regression tests for auth-disabled document access (PR #4623).
Validates that the _auth_disabled() bypass in _verify_doc_owner and
list_documents restores single-user / no-auth mode WITHOUT weakening the
authenticated path. Three pinned directions:
1. AUTH_DISABLED + None user -> list_documents + doc read SUCCEEDS
(the bug being fixed).
2. AUTH_ENABLED + None user -> still 403.
3. AUTH_ENABLED + wrong owner -> _verify_doc_owner still raises 404/403.
Route handlers are called directly (same pattern as
test_document_session_owner_scope.py) so coverage lands on the real
closures without spinning up middleware.
"""
import tempfile
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from tests.helpers.import_state import clear_fake_database_modules
clear_fake_database_modules()
import core.database as cdb
import routes.document_routes as droutes
from core.database import Document
from core.database import Session as DbSession
from routes.document_helpers import _verify_doc_owner, _owner_session_filter
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_ENGINE = create_engine(
f"sqlite:///{_TMPDB.name}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(_ENGINE)
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
# ------------------------------------------------------------------ helpers
def _req(user=None):
"""Build a minimal fake Request whose state.current_user returns *user*."""
return SimpleNamespace(state=SimpleNamespace(current_user=user))
def _endpoint(method, path):
"""Resolve a route endpoint from the document router."""
router = droutes.setup_document_routes(MagicMock(), None)
for route in router.routes:
if getattr(route, "path", None) == path and method in getattr(route, "methods", set()):
return route.endpoint
raise RuntimeError(f"{method} {path} not found")
def _bind_test_db():
previous = droutes.SessionLocal
droutes.SessionLocal = _TS
return previous
def _seed(owner="alice"):
"""Create one session + one owned document. Returns (session_id, doc_id)."""
session_id = f"{owner}-" + uuid.uuid4().hex[:8]
doc_id = str(uuid.uuid4())
db = _TS()
try:
db.add(DbSession(
id=session_id, owner=owner, name=owner,
model="m", endpoint_url="http://x",
))
db.add(Document(
id=doc_id,
session_id=session_id,
title=f"{owner} doc",
language="markdown",
current_content=f"{owner} body",
version_count=1,
is_active=True,
owner=owner,
))
db.commit()
return session_id, doc_id
finally:
db.close()
# ------------------------------------------------------ 1. auth DISABLED +
# None user -> succeeds
@pytest.mark.asyncio
async def test_list_documents_allows_none_user_when_auth_disabled(monkeypatch):
"""AUTH_ENABLED=false + user=None must NOT raise 403 on list_documents."""
monkeypatch.setenv("AUTH_ENABLED", "false")
previous = _bind_test_db()
try:
list_docs = _endpoint("GET", "/api/documents/{session_id}")
session_id, doc_id = _seed()
# Must succeed — this is the bug fix.
rows = await list_docs(_req(None), session_id)
ids = [row["id"] for row in rows]
assert doc_id in ids, "own doc must be visible in auth-disabled mode"
finally:
droutes.SessionLocal = previous
@pytest.mark.asyncio
async def test_get_document_allows_none_user_when_auth_disabled(monkeypatch):
"""AUTH_ENABLED=false + user=None must NOT raise 403 on get_document."""
monkeypatch.setenv("AUTH_ENABLED", "false")
previous = _bind_test_db()
try:
get_doc = _endpoint("GET", "/api/document/{doc_id}")
_session_id, doc_id = _seed()
# Must succeed — _verify_doc_owner bypasses when auth is disabled.
result = await get_doc(_req(None), doc_id)
assert result["id"] == doc_id
finally:
droutes.SessionLocal = previous
def test_verify_doc_owner_allows_none_user_when_auth_disabled(monkeypatch):
"""_verify_doc_owner with user=None + AUTH_ENABLED=false must pass."""
monkeypatch.setenv("AUTH_ENABLED", "false")
_session_id, doc_id = _seed()
db = _TS()
try:
doc = db.query(Document).filter(Document.id == doc_id).first()
# Must NOT raise — the bypass allows single-user access.
_verify_doc_owner(db, doc, None)
finally:
db.close()
def test_owner_session_filter_noops_for_none_user_when_auth_disabled(monkeypatch):
"""_owner_session_filter with user=None + AUTH_ENABLED=false returns query unchanged."""
monkeypatch.setenv("AUTH_ENABLED", "false")
_session_id, doc_id = _seed()
db = _TS()
try:
q = db.query(Document).filter(Document.id == doc_id)
result = _owner_session_filter(q, None)
# Filter was a no-op; document is still reachable.
assert result.first().id == doc_id
finally:
db.close()
# ------------------------------------------------------ 2. auth ENABLED +
# None user -> 403
@pytest.mark.asyncio
async def test_list_documents_rejects_none_user_when_auth_enabled(monkeypatch):
"""AUTH_ENABLED=true (default) + user=None must raise 403."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
previous = _bind_test_db()
try:
list_docs = _endpoint("GET", "/api/documents/{session_id}")
session_id, _doc_id = _seed()
with pytest.raises(HTTPException) as exc:
await list_docs(_req(None), session_id)
assert exc.value.status_code == 403
finally:
droutes.SessionLocal = previous
@pytest.mark.asyncio
async def test_get_document_rejects_none_user_when_auth_enabled(monkeypatch):
"""AUTH_ENABLED=true (default) + user=None must raise 403 via _verify_doc_owner."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
previous = _bind_test_db()
try:
get_doc = _endpoint("GET", "/api/document/{doc_id}")
_session_id, doc_id = _seed()
with pytest.raises(HTTPException) as exc:
await get_doc(_req(None), doc_id)
assert exc.value.status_code == 403
finally:
droutes.SessionLocal = previous
def test_verify_doc_owner_rejects_none_user_when_auth_enabled(monkeypatch):
"""_verify_doc_owner with user=None + AUTH_ENABLED=true must raise 403."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
_session_id, doc_id = _seed()
db = _TS()
try:
doc = db.query(Document).filter(Document.id == doc_id).first()
with pytest.raises(HTTPException) as exc:
_verify_doc_owner(db, doc, None)
assert exc.value.status_code == 403
finally:
db.close()
# ------------------------------------------ 3. auth ENABLED + wrong owner ->
# _verify_doc_owner raises 404
def test_verify_doc_owner_rejects_wrong_owner_when_auth_enabled(monkeypatch):
"""_verify_doc_owner with a mismatched owner must raise 404 (not 403).
This confirms the authenticated path is untouched by the no-auth bypass."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
session_id, doc_id = _seed(owner="alice")
db = _TS()
try:
doc = db.query(Document).filter(Document.id == doc_id).first()
with pytest.raises(HTTPException) as exc:
_verify_doc_owner(db, doc, "bob") # bob != alice
assert exc.value.status_code == 404
finally:
db.close()
@pytest.mark.asyncio
async def test_get_document_rejects_wrong_owner(monkeypatch):
"""GET /api/document/{doc_id} with wrong authenticated user -> 404."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
previous = _bind_test_db()
try:
get_doc = _endpoint("GET", "/api/document/{doc_id}")
_session_id, doc_id = _seed(owner="alice")
with pytest.raises(HTTPException) as exc:
await get_doc(_req("bob"), doc_id)
assert exc.value.status_code == 404
finally:
droutes.SessionLocal = previous
@pytest.mark.asyncio
async def test_list_documents_hides_wrong_owner_docs(monkeypatch):
"""list_documents for alice must not show bob's documents."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
previous = _bind_test_db()
try:
list_docs = _endpoint("GET", "/api/documents/{session_id}")
# Seed alice's session with a doc
alice_session, alice_doc = _seed(owner="alice")
# Create bob's session+doc in the SAME session so ownership filter kicks in
bob_session = "bob-" + uuid.uuid4().hex[:8]
bob_doc = str(uuid.uuid4())
db = _TS()
try:
db.add(DbSession(id=bob_session, owner="bob", name="bob", model="m", endpoint_url="http://x"))
db.add(Document(
id=bob_doc, session_id=alice_session, # same session!
title="bob doc", language="markdown", current_content="bob body",
version_count=1, is_active=True, owner="bob",
))
db.commit()
finally:
db.close()
rows = await list_docs(_req("alice"), alice_session)
ids = [row["id"] for row in rows]
assert alice_doc in ids
assert bob_doc not in ids, "wrong-owner docs must be hidden"
finally:
droutes.SessionLocal = previous
+107
View File
@@ -0,0 +1,107 @@
r"""DOM/CSS-injection regression for calendar background-image URL escaping.
CodeQL `js/incomplete-sanitization` (#463 calendar.js:416, #464 calendar.js:1263)
flagged event-background CSS that escaped `'` -> `\'` without first escaping
backslashes. A `bg:`-color value (settable per event, and CalDAV-syncable, so
untrusted) ending in or containing a backslash can then consume the closing
quote of `url('...')` and break out of the CSS string.
The fix is a single canonical escaper, `_cssUrlEscape`, in calendar/utils.js,
used by both inline sinks and by `_calBgCss` (which had the same incomplete
escaping). These tests pin the escaper: backslashes are doubled FIRST, then
quotes, so no input can terminate the `url('...')` string early.
"""
import json
import re
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_UTILS = (_REPO / "static" / "js" / "calendar" / "utils.js").as_posix()
_CALENDAR_JS = _REPO / "static" / "js" / "calendar.js"
_HAS_NODE = shutil.which("node") is not None
pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def _run(js: str) -> str:
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return proc.stdout.strip()
def test_cssurlescape_doubles_backslashes_before_quotes():
js = textwrap.dedent(
f"""
const {{ _cssUrlEscape }} = await import('{_UTILS}');
console.log(JSON.stringify({{
backslash: _cssUrlEscape('a\\\\b'),
trailing: _cssUrlEscape('img\\\\'),
quote: _cssUrlEscape("a'b"),
dquote: _cssUrlEscape('a"b'),
}}));
"""
)
out = json.loads(_run(js))
# one backslash -> two; the escape for "'" is not itself re-escaped
assert out["backslash"] == r"a\\b"
assert out["trailing"] == "img\\\\" # 'img\' -> 'img\\'
assert out["quote"] == r"a\'b"
assert out["dquote"] == "a%22b"
def test_backslash_breakout_payload_cannot_close_the_url_string():
# Without the backslash-first escape, "x\" would render url('x\') and the
# trailing backslash escapes the closing quote -> breakout. After the fix the
# backslash is doubled, so the quote we add still terminates the string.
js = textwrap.dedent(
f"""
const {{ _cssUrlEscape, _calBgCss }} = await import('{_UTILS}');
const payload = 'x\\\\'; // a string ending in one backslash
console.log(JSON.stringify({{
esc: _cssUrlEscape(payload),
css: _calBgCss('bg:' + payload, 'var(--accent)'),
}}));
"""
)
out = json.loads(_run(js))
assert out["esc"] == "x\\\\" # doubled backslash
# The rendered declaration keeps the backslash doubled inside url('...').
assert "url('x\\\\')" in out["css"]
def test_calbgcss_escapes_quote_breakout():
js = textwrap.dedent(
f"""
const {{ _calBgCss }} = await import('{_UTILS}');
console.log(JSON.stringify(_calBgCss("bg:a'); X{{}}//", 'var(--accent)')));
"""
)
css = json.loads(_run(js))
# the injected single quote is escaped, so the url() string is not closed early
assert r"\'" in css
assert "url('a\\'); X{}//')" in css
def test_every_calendar_url_interpolation_is_escaped():
# Whole-file invariant: every CSS `url('${...}')` built in calendar.js must
# route its (CalDAV-syncable, untrusted) value through `_cssUrlEscape`. This
# is the guard that catches a *newly added* bg-image sink the centralization
# forgot - the failure mode that left calendar.js:2856 (edit-form color
# swatch) and :2953 (custom-dot preview) raw before this change.
src = _CALENDAR_JS.read_text(encoding="utf-8")
interps = re.findall(r"url\('\$\{([^}]*)\}'\)", src)
assert interps, "expected at least one url('${...}') interpolation in calendar.js"
unescaped = [expr for expr in interps if "_cssUrlEscape(" not in expr]
assert not unescaped, (
"bg-image url() interpolation(s) not routed through _cssUrlEscape: "
+ ", ".join(repr(e) for e in unescaped)
)
+47
View File
@@ -72,3 +72,50 @@ def test_gguf_alternate_still_recommended_on_windows():
still appear on Windows even though the AWQ variant is hidden."""
names = {r["name"] for r in rank_models(_windows_system(), limit=900)}
assert "Qwen/Qwen2.5-3B-Instruct" in names
def test_remote_windows_probe_uses_encoded_command(monkeypatch):
"""Remote Windows hwfit must not use nested -Command quoting over SSH."""
from services.hwfit import hardware
calls = []
monkeypatch.setattr(hardware, "_remote_host", "user@winpc")
monkeypatch.setattr(hardware, "_remote_port", None)
def fake_run(cmd):
calls.append(cmd)
if isinstance(cmd, str) and "EncodedCommand" in cmd:
return (
'{"ram_gb":64,"avail_gb":32,"cpu_name":"Test CPU",'
'"cpu_cores":8,"arch":64}'
)
return None
monkeypatch.setattr(hardware, "_run", fake_run)
result = hardware._detect_windows()
assert result is not None
assert result["total_ram_gb"] == 64
assert len(calls) == 1
assert "EncodedCommand" in calls[0]
assert '-Command "' not in calls[0]
def test_probe_remote_platform_detects_windows(monkeypatch):
from services.hwfit import hardware
monkeypatch.setattr(hardware, "_run", lambda cmd: "Windows_NT\n")
assert hardware._probe_remote_platform() == "windows"
def test_probe_remote_platform_detects_darwin(monkeypatch):
from services.hwfit import hardware
def fake_run(cmd):
if cmd == "echo %OS%":
return "%OS%"
if cmd == ["uname", "-s"]:
return "Darwin"
raise AssertionError(f"unexpected probe cmd: {cmd!r}")
monkeypatch.setattr(hardware, "_run", fake_run)
assert hardware._probe_remote_platform() == "linux"
+156
View File
@@ -0,0 +1,156 @@
"""Tests for _normalize_mistral_content() — Mistral's structured content parser.
Mistral's chat completions API returns content as a typed array when reasoning
is enabled, instead of the plain string most OpenAI-compat servers use:
"content": [
{"type": "thinking", "thinking": [{"type": "text", "text": "..."}], "closed": true},
{"type": "text", "text": "..."}
]
_normalize_mistral_content() splits that into (text, thinking) plain strings.
The function is called from three sites:
- llm_call (sync, non-streaming response parser)
- llm_call_async (async, non-streaming response parser)
- stream_llm (streaming delta parser)
These tests pin the contract: string passthrough, the array shape, and the
edge cases (empty, garbage, missing fields) so a refactor doesn't silently
drop thinking content or break non-Mistral providers.
"""
from src.llm_core import _normalize_mistral_content
def test_string_passthrough_returns_text_with_empty_thinking():
"""Plain string content (the common case) passes through unchanged."""
text, thinking = _normalize_mistral_content("hello world")
assert text == "hello world"
assert thinking == ""
def test_empty_string_passthrough():
text, thinking = _normalize_mistral_content("")
assert text == ""
assert thinking == ""
def test_array_with_thinking_and_text_blocks():
"""Mistral's documented format: thinking block + text block."""
content = [
{
"type": "thinking",
"thinking": [{"type": "text", "text": "Let me work through this..."}],
"closed": True,
},
{"type": "text", "text": "The answer is 42."},
]
text, thinking = _normalize_mistral_content(content)
assert text == "The answer is 42."
assert thinking == "Let me work through this..."
def test_array_with_only_thinking_block():
"""Streaming deltas often contain only a thinking fragment (no text block yet)."""
content = [
{
"type": "thinking",
"thinking": [{"type": "text", "text": "Okay, let's"}],
"closed": True,
}
]
text, thinking = _normalize_mistral_content(content)
assert text == ""
assert thinking == "Okay, let's"
def test_array_with_only_text_block():
"""Final answer delta — only the text block, no thinking."""
content = [{"type": "text", "text": "Final answer."}]
text, thinking = _normalize_mistral_content(content)
assert text == "Final answer."
assert thinking == ""
def test_array_concatenates_multiple_text_blocks():
"""Multiple text blocks are concatenated in order."""
content = [
{"type": "text", "text": "part 1 "},
{"type": "text", "text": "part 2"},
]
text, thinking = _normalize_mistral_content(content)
assert text == "part 1 part 2"
def test_array_concatenates_multiple_thinking_fragments():
"""Multiple thinking sub-blocks are concatenated in order."""
content = [
{
"type": "thinking",
"thinking": [
{"type": "text", "text": "first "},
{"type": "text", "text": "second"},
],
"closed": True,
}
]
text, thinking = _normalize_mistral_content(content)
assert text == ""
assert thinking == "first second"
def test_empty_array_returns_empty_strings():
text, thinking = _normalize_mistral_content([])
assert text == ""
assert thinking == ""
def test_array_with_garbage_entries_skips_them():
"""Non-dict entries, missing type, missing text — all silently skipped."""
content = [
"not a dict",
None,
{"type": "unknown_type", "text": "should be ignored"},
{"type": "text"}, # missing text key
{"type": "thinking"}, # missing thinking key
{"type": "text", "text": "valid text"},
]
text, thinking = _normalize_mistral_content(content)
assert text == "valid text"
assert thinking == ""
def test_none_returns_empty_strings():
"""Defensive: None content (server bug or schema drift) doesn't crash."""
text, thinking = _normalize_mistral_content(None)
assert text == ""
assert thinking == ""
def test_int_returns_empty_strings():
"""Defensive: wrong-typed content doesn't crash."""
text, thinking = _normalize_mistral_content(42)
assert text == ""
assert thinking == ""
def test_thinking_block_with_string_inner():
"""Some Mistral API versions may use a string instead of an array for
the inner 'thinking' field. Accept both shapes."""
content = [
{"type": "thinking", "thinking": "inline string thinking"},
{"type": "text", "text": "answer"},
]
text, thinking = _normalize_mistral_content(content)
assert text == "answer"
assert thinking == "inline string thinking"
def test_thinking_block_with_empty_text_field():
"""Empty text fields don't pollute the output."""
content = [
{"type": "thinking", "thinking": [{"type": "text", "text": ""}], "closed": True},
{"type": "text", "text": ""},
]
text, thinking = _normalize_mistral_content(content)
assert text == ""
assert thinking == ""
+32
View File
@@ -0,0 +1,32 @@
from core.log_safety import redact_url
def test_strips_userinfo():
assert redact_url("https://user:pass@host.example/v1/models") == "https://host.example/v1/models"
def test_strips_query_and_fragment():
assert redact_url("https://host.example/v1?api_key=secret#frag") == "https://host.example/v1"
def test_keeps_port_and_path():
assert redact_url("http://host.example:8080/api/tags") == "http://host.example:8080/api/tags"
def test_ipv6_host_keeps_brackets():
assert redact_url("https://user:pass@[2001:db8::1]:8443/v1") == "https://[2001:db8::1]:8443/v1"
assert redact_url("https://[2001:db8::1]/v1") == "https://[2001:db8::1]/v1"
def test_no_credentials_passthrough():
assert redact_url("https://host.example/v1/models") == "https://host.example/v1/models"
def test_empty_and_none():
assert redact_url("") == ""
assert redact_url(None) == ""
def test_garbage_does_not_raise():
# urlparse is lenient; just assert no credential-looking userinfo survives.
assert "@" not in redact_url("::::not a url::::")
+30
View File
@@ -170,6 +170,36 @@ def test_extract_thinking_blocks_handles_thought_tag(node_available):
assert result["content"] == "Final answer."
def test_url_inside_inline_code_is_not_autolinked(node_available):
# A URL inside a backtick span is preceded by a space, so the bare-URL
# autolink used to wrap it in an <a> tag (then swap it for an
# ___ALLOWED_HTML_ placeholder), corrupting the command shown to the user.
html = _run_markdown_case("Run `$j = irm http://127.0.0.1:3000/x` to fetch.")
assert "<code>$j = irm http://127.0.0.1:3000/x</code>" in html
assert "___ALLOWED_HTML_" not in html
assert "<a " not in html
assert 'href="http://127.0.0.1:3000/x"' not in html
def test_url_outside_inline_code_is_still_autolinked(node_available):
# Inline code must not disable autolinking for bare URLs elsewhere in the
# same line.
html = _run_markdown_case("Use `irm` then visit https://example.com/page now.")
assert "<code>irm</code>" in html
assert 'href="https://example.com/page"' in html
def test_inline_code_content_is_html_escaped(node_available):
# Inline code is now extracted before the global escape pass, so it must be
# escaped at extraction time (matching the fenced-code-block handling).
html = _run_markdown_case("Render `<b>$1 & 'q'</b>` literally.")
assert "<code>&lt;b&gt;$1 &amp; &#39;q&#39;&lt;/b&gt;</code>" in html
assert "<b>" not in html
def test_dotted_python_import_paths_are_not_autolinked(node_available):
html = _run_markdown_case(
"from imblearn.combine import SMOTETomek\n"
+109
View File
@@ -0,0 +1,109 @@
"""Regression tests for Ollama-native multimodal image routing (issue #4723).
Odysseus builds user messages in OpenAI style::
{"role": "user", "content": [
{"type": "text", "text": "..."},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
]}
Native Ollama ``/api/chat`` does **not** accept a list for ``content``. It
expects ``content`` to be a string and images carried separately on
``images`` (a list of raw base64 strings, no ``data:`` prefix). Without
this conversion the image block silently never reaches the vision model
the model reports "I can't see the image" even though it is vision-capable
and the request succeeded.
"""
from src import llm_core
def _multimodal_msg():
return {
"role": "user",
"content": [
{"type": "text", "text": "What is in this picture?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,BBBB"}},
],
}
def test_ollama_payload_converts_openai_image_blocks_to_native_images_array():
payload = llm_core._build_ollama_payload(
"gemma4:e4b", [_multimodal_msg()], temperature=0.0, max_tokens=0,
)
msg = payload["messages"][0]
# Content must be a string, not a list — native Ollama rejects lists.
assert isinstance(msg["content"], str)
assert "What is in this picture?" in msg["content"]
# Base64 data extracted into the native images array (no data: prefix).
assert msg["images"] == ["AAAA", "BBBB"]
def test_ollama_payload_skips_http_image_url():
"""Non-data-URI image_url values are skipped with a warning because
native Ollama images[] accepts base64 only."""
msg = {
"role": "user",
"content": [
{"type": "text", "text": "Look"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
],
}
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
out = payload["messages"][0]
assert out["content"] == "Look"
# HTTP URL is NOT added to images — Ollama cannot fetch it.
assert "images" not in out
def test_ollama_payload_preserves_native_images_array():
"""If the caller already used Ollama's native shape, leave it alone."""
msg = {
"role": "user",
"content": "Describe",
"images": ["XXXX"],
}
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
out = payload["messages"][0]
assert out["content"] == "Describe"
assert out["images"] == ["XXXX"]
def test_ollama_payload_merges_native_and_openai_images():
"""A message that carries both native ``images`` and OpenAI ``image_url``
blocks (e.g. assembled by different code paths) must produce one combined
list rather than drop either half."""
msg = {
"role": "user",
"content": [
{"type": "text", "text": "Hi"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,OPENAI"}},
],
"images": ["NATIVE"],
}
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
out = payload["messages"][0]
assert out["content"] == "Hi"
assert out["images"] == ["NATIVE", "OPENAI"]
def test_ollama_payload_text_only_message_untouched():
msgs = [{"role": "user", "content": "hello"}]
payload = llm_core._build_ollama_payload("gemma4:e4b", msgs, temperature=0.0, max_tokens=0)
assert payload["messages"][0] == {"role": "user", "content": "hello"}
def test_ollama_payload_string_content_with_only_image_block():
"""A message whose content list has only image_url blocks (no text part)
still yields a non-empty content string so native Ollama accepts it."""
msg = {
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,QQ=="}},
],
}
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
out = payload["messages"][0]
assert isinstance(out["content"], str)
assert out["images"] == ["QQ=="]
+89
View File
@@ -0,0 +1,89 @@
"""Node-driven regression coverage for body-portaled dropdown z-order.
Tool-modal z climbs unbounded via modalManager's bring-to-front counter, so the
old hardcoded `z-index: 10001` shared by ~16 body-portaled dropdowns eventually
rendered them BEHIND their own modal in a long session (#4720). topPortalZ()
replaces every one of those literals with a value derived from the live
tool-window stack. These tests pin that it always clears both the modal stack
and the dock-chip floor, without importing the browser-heavy UI modules.
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
HELPER = ROOT / "static" / "js" / "toolWindowZOrder.js"
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
def _node_eval(source: str):
proc = subprocess.run(
["node", "--input-type=module"],
input=source,
cwd=ROOT,
capture_output=True,
text=True,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_portal_z_clears_dock_chip_floor_when_no_modal_is_open():
# No tool window raised → topToolWindowZ floors at 250, but a portaled
# dropdown must still clear the dock chips pinned up to 10030, so it lands
# just above that floor.
values = _node_eval(
textwrap.dedent(
f"""
import {{ topPortalZ }} from '{HELPER.as_uri()}';
const root = {{ querySelectorAll() {{ return []; }} }};
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: () => ({{}}) }}) }}));
"""
)
)
assert values == {"z": 10031}
def test_portal_z_sits_above_a_modal_whose_counter_has_climbed_past_10001():
# The #4720 scenario: a long session bumped the owning modal's bring-to-front
# z to 99999. A hardcoded 10001 dropdown rendered BEHIND it; topPortalZ must
# land one above the live modal z.
values = _node_eval(
textwrap.dedent(
f"""
import {{ topPortalZ }} from '{HELPER.as_uri()}';
const cls = (...names) => ({{ contains: (name) => names.includes(name) }});
const modal = {{ id: 'memory-modal', classList: cls(), style: {{ zIndex: '99999' }} }};
const root = {{ querySelectorAll() {{ return [modal]; }} }};
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }}));
"""
)
)
assert values == {"z": 100000}
def test_portal_z_uses_chip_floor_when_the_open_modal_sits_below_it():
# A modal raised to 5000 is still below the dock-chip floor, so the floor
# (10030) wins and the dropdown lands at 10031 — never below a pinned chip.
values = _node_eval(
textwrap.dedent(
f"""
import {{ topPortalZ }} from '{HELPER.as_uri()}';
const cls = (...names) => ({{ contains: (name) => names.includes(name) }});
const modal = {{ id: 'cookbook-modal', classList: cls(), style: {{ zIndex: '5000' }} }};
const root = {{ querySelectorAll() {{ return [modal]; }} }};
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }}));
"""
)
)
assert values == {"z": 10031}
+49
View File
@@ -0,0 +1,49 @@
r"""Regression test for ReDoS in the calendar-extract fallback regex.
CodeQL `py/redos` (#198) flagged the inline array-matcher in
`email_pollers.py` that recovers a `[{"action": ...}, ...]` JSON array from
raw LLM output (influenced by attacker-supplied email bodies). The original
pattern used `[^[\]]*?` lazy runs inside a `(...)*` repetition, which
backtracks *exponentially* on inputs like `[{"action"},{` + `}},{{` * N.
The regex is now a module-level constant so it can be pinned here. These tests
assert it (a) still extracts well-formed action arrays and (b) returns
promptly on the adversarial input that hung the old pattern.
"""
import time
from routes.email_pollers import _CAL_ACTION_ARRAY_RE
def _matches(s):
return [m.group() for m in _CAL_ACTION_ARRAY_RE.finditer(s)]
def test_extracts_action_array_from_prose():
s = 'Here you go:\n[{"action":"add","title":"Standup","start":"2026-07-01T09:00"}]\nThanks!'
assert _matches(s) == ['[{"action":"add","title":"Standup","start":"2026-07-01T09:00"}]']
def test_extracts_multi_object_array():
s = 'prose [{"action":"add","title":"A"},{"action":"cancel","uid":"x"}] tail'
assert _matches(s) == ['[{"action":"add","title":"A"},{"action":"cancel","uid":"x"}]']
def test_no_array_returns_no_match():
assert _matches("no array here at all") == []
def test_bracket_in_string_value_still_extracts():
# The old `[^[\]]` class bailed on a '[' inside a value and matched nothing;
# the linear `[^{}]` form correctly recovers the array.
s = '[{"action":"add","title":"Meeting [urgent]","start":"x"}]'
assert _matches(s) == [s]
def test_adversarial_input_is_fast():
evil = '[{"action"},{' + '}},{{' * 100_000 # exploded the old exponential pattern
start = time.perf_counter()
_CAL_ACTION_ARRAY_RE.search(evil)
dt = time.perf_counter() - start
assert dt < 1.0, f"_CAL_ACTION_ARRAY_RE took {dt:.2f}s on adversarial input"
@@ -0,0 +1,29 @@
import re
import subprocess
from pathlib import Path
def test_opencode_setup_provider_aliases_resolve():
source = Path("static/js/slashCommands.js").read_text()
match = re.search(
r"const SETUP_PROVIDER_URLS = \{[\s\S]*?\nfunction _normalizeSetupBaseUrl",
source,
)
assert match, "setup provider helper block not found"
helper_source = match.group(0).removesuffix("\nfunction _normalizeSetupBaseUrl")
script = helper_source + r"""
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const zenFromCommand = _setupProviderFromInput('opencode zen');
assert(zenFromCommand && zenFromCommand.url === 'https://opencode.ai/zen/v1', 'opencode zen command alias failed');
const goFromCommand = _setupProviderFromInput('opencode-go');
assert(goFromCommand && goFromCommand.url === 'https://opencode.ai/zen/go/v1', 'opencode-go command alias failed');
const zenCredential = _extractSetupProviderCredential('opencode-zen sk-test');
assert(zenCredential && zenCredential.provider.name === 'OpenCode Zen', 'opencode-zen credential provider failed');
assert(zenCredential.credential === 'sk-test', 'opencode-zen credential extraction failed');
const goCredential = _extractSetupProviderCredential('opencode go sk-test');
assert(goCredential && goCredential.provider.name === 'OpenCode Go', 'opencode go credential provider failed');
assert(goCredential.credential === 'sk-test', 'opencode go credential extraction failed');
"""
subprocess.run(["node", "-e", script], check=True)