Docking a modal to a window edge pushes the chat aside (body padding via
right-dock-active + --right-dock-w). Three problems on close/reopen:
1. Chat stayed offset after closing a docked modal. The close-watcher only
reacted to the `.hidden` class or DOM removal, but the draggable modals
(calendar, plan, workspace, document, …) close via inline `display:none`.
Watch the `style` attribute too and treat `display:none` as closed.
2. Reopening a previously-docked singleton modal floated it off to the side,
overlapping the chat. The reused element kept its docked inline geometry.
Clear the content's inline position/size on close so it reopens at its CSS
default (centered).
3. Undock wasn't animated. The transition lived on `.right/left-dock-active`,
so removing the class dropped the transition with it and padding snapped to
0. Move the transition to the base `body` so the push animates both ways.
Files: static/js/modalSnap.js, static/style.css.
Checks: node --check static/js/modalSnap.js; verified in-browser (dock → close
→ chat animates back; reopen → centered, no overlap).
Multimodal content (list of {type, text/image_url} blocks) couldn't be
stored in the DB Text column, causing silent persist failures. On reload
the frontend fell back to String() on the array, rendering
[object Object],[object Object] in the chat.
- Serialize list content as JSON in _persist_message()
- Deserialize back to list in _db_to_session() via _parse_msg_content()
- Extract text parts from multimodal arrays in sessions.js instead of
String() coercion
CJK and other IME users confirm a candidate from the input-method popup by pressing Enter. The chat composer and the in-place message editor each bind a keydown handler that treats Enter (without Shift) as "submit", but they did not exclude the composition state. Pressing Enter to accept an IME candidate therefore sent the half-composed text (e.g. a stray "ce's") instead of just confirming the candidate.
These textareas intentionally hijack Enter to submit (Enter sends, Shift+Enter inserts a newline), which bypasses the browser's native form submission and the IME guard that comes with it, so the guard has to be re-added explicitly.
Add '&& !e.isComposing' to the three Enter-to-submit handlers: static/app.js (the main composer's button-submit path and its send/new-chat path) and static/js/chat.js (the editor for an already-sent message). Normal Enter (isComposing false) still submits; Shift+Enter still inserts a newline.
Tested: node --check on both files; manually verified with a Chinese IME that pressing Enter to pick a candidate no longer sends, and a message is sent only after composition ends.
Surfaces the research_run_timeout_seconds setting (added in #783) in
Settings → Research as a "Max Time" field, and lets 0 disable the
wall-clock cap entirely for long deep-research runs.
- settings.py: document that 0 disables the cap; default stays 1800s.
- research_handler.py: resolve 0 (or negative) to no timeout
(asyncio.wait_for timeout=None); other values stay bounded to
[60, 86400] as before.
- index.html / settings.js: "Max Time" input bound to
research_run_timeout_seconds, validated to {0} ∪ [60, 86400], with
copy making explicit that 0 = no limit (unbounded model/API cost).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
Self-hosted endpoints on a LAN are sometimes protected by an API key. The admin
"Local" add/test form only sent base_url (+ model_type), so such an endpoint
could not be added — it just errored out — even though the backend
POST /api/model-endpoints and /model-endpoints/test already accept an optional
api_key form field (the cloud "API" form already uses it).
Adds an optional masked "API key" input (adm-epLocalApiKey) to the Local form
and wires it into the local Test and Add handlers, sending api_key only when
filled (an empty value is omitted so we never send a blank Bearer). The field
is cleared after a successful add, matching the cloud form.
Tested: tests/test_local_endpoint_api_key_js.py extracts the two click handlers
and runs them under node with mocked DOM/FormData/fetch, asserting api_key is
sent when the field is filled and omitted when blank, plus that the input
exists as a password field. `node --check static/js/admin.js` passes.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
`_ORIG_RE` (and its JS mirror `_TALON_ORIG_RE`) already recognised the
Japanese forward marker `転送` alongside the "Original Message" delimiters,
but not the English "Forwarded message" one. So Gmail-style forwards —
including the ones Odysseus itself emits (`---------- Forwarded message
----------`, static/js/emailInbox.js) — were not treated as a quote
boundary:
- with a following Outlook From:/Date: header block, the divider line
leaked into the level-0 reply bubble as noise;
- with only the divider marking the forward (no header block), the body
was not split into turns at all.
Add `Forwarded\s+message` to the same `[-_=]{3,}`-delimited alternation in
both the server-side parser and the JS mirror, so forward dividers are
consumed as an attribution boundary like "----- Original Message -----".
Locale variants of "Forwarded message" can follow the existing pattern.
Tests cover both manifestations plus a negative control (the bare words
"forwarded message" without `[-_=]{3,}` delimiters must not split).
Checks: python -m pytest tests/test_forwarded_message_divider.py (3 passed),
python -m py_compile src/email_thread_parser.py, node --check
static/js/emailLibrary/utils.js, git diff --check.
The list keyboard handler (_onSessionListKeydown) treats Backspace and
Delete as "delete the focused session". When the user double-clicks a
chat to rename it, an <input class="session-rename-input"> is mounted
inside the .list-item row. Backspace on the input bubbles up to the list
container, the handler walks closest('.list-item[data-session-id]') from
e.target, finds the parent row and DELETEs the session via the API —
so a single typo correction nukes the whole conversation.
Bail out at the top of the handler when e.target is an INPUT, TEXTAREA,
or contentEditable element. Arrow / Enter / Delete navigation still
works for rows themselves (the row is the focused element then, not the
input). Mirrors the guard pattern already used in ui.js, notes.js,
tasks.js, calendar.js, emailLibrary.js and galleryEditor.js.
Closes#1007.
Rebased on current main. Integrates with the new Recent/Favorites
system — provider groups appear below Recent and Favorites in browse
mode for large catalogs (>12 models).
Changes:
- Models grouped by canonical provider with collapsible sections
- Chevron animation consistent with sidebar sections
- Domino cascade on expand (only on just-opened group)
- Provider display names (deepseek-ai -> DeepSeek, meta -> Llama, etc.)
- Alias merging (meta + meta-llama -> one Llama group)
- Search includes provider display names for filtering
- Collapsed state persists in localStorage
- No screenshot binary committed
Co-authored-by: danielxb <5981902+danielxb@users.noreply.github.com>
The Fit column shared the Score column's sort key, so clicking the Fit
header sorted by Score instead of by hardware fit. There was also no
fit option in the hidden sort <select> and no fit branch in the
client-side comparator.
- Give the Fit column its own sort key (fit).
- Add a fit option to the sort select (kept Score as the default so
first-load ordering is unchanged).
- Sort by the categorical fit_level rank
(perfect > good > marginal > too_tight), tie-broken by score, honoring
the ascending/descending toggle.
Fixes#842
Co-authored-by: SabixMaru <285860855+SabixMaru@users.noreply.github.com>
* Fix Cookbook dependency install completion state
Mark Cookbook dependency installs as complete when the background runner
exits successfully, even when HuggingFace-specific download markers are
absent.
* Add focused regression coverage for cookbook dependency completion.
Keep the fix narrowly scoped while carrying env_path through dependency tasks and locking the completion reconciliation behavior with targeted tests.
* fixed confusing credentials prompt
* fix(setup): return status from create_default_admin function
* fix(setup): initialize admin creation status in main function
* fix(setup): enhance admin creation feedback and status handling
* Enhance admin user login messages with conditional feedback based on creation status
* Refine admin user creation feedback messages for clarity and actionability and formatted code
* Add fallback error message for admin creation failure in setup script
* Add run script for Uvicorn with dotenv integration
* Refactor server runner to use argparse for host and port configuration
* Remove captured output print statement from server runner
* Fix server runner to ensure cross-platform compatibility and improve log handling
* Remove run.py script to match main repo
* feat: add custom option for search result count in settings
* fix: enforce minimum and maximum values for custom search result count
Issue #234: the "Character" tab and its "Style of response" label made it
unclear that this is where a system prompt is set. Rename the user-facing
labels for clarity:
- "Character" tab + section heading -> "Persona"
- "Style of response" -> "System prompt"
- supporting strings: select placeholder, name placeholder, button/title
text, toasts, confirm/notice text, the chat-bar indicator tooltip, the
settings visibility toggle, and the assistant personality picker
("Characters" optgroup -> "Personas").
Used "Persona" rather than the issue's suggested "Preset" because the app
already has a distinct, user-facing "Presets" concept (built-in presets
like Code Analyze/Brainstorm/Reason, shown as their own group in the
assistant picker). "Persona" matches what this tab actually creates -- a
named persona with its own memories -- without colliding with that term.
Internal identifiers (element IDs, data-chartab attributes, function names)
and the character_name backend field are intentionally left unchanged so
existing saved presets and JS wiring keep working.
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
* Cookbook: Engine filter + intelligent hardware-computed serve profiles
Two related Cookbook serving improvements for accurate, hardware-aware model
serving (especially on consumer GPUs that can only run GGUF/llama.cpp).
Engine filter
- New "Engine" dropdown (All / llama.cpp / vLLM / SGLang) beside the quant
picker. Pure client-side view filter over the fetched list via the same
_detectBackend() the serve commands use, so what you filter to is exactly what
would launch. Re-renders from cache (no refetch). Empty-state message + the
instant-cache-paint path account for it too.
Intelligent serve profiles (Quality / Balanced / Speed)
- services/hwfit/profiles.py: compute_serve_profiles() turns detected VRAM +
model size into concrete llama.cpp flags (n_gpu_layers, n_cpu_moe, cache-type,
context). Encodes the by-hand tuning: a too-big MoE offloads experts to CPU
instead of failing; a model that fits stays fully on GPU; quant tracks profile
intent; vision models keep image-encoder headroom. Reuses models.py VRAM math
so filtering and serving agree on what fits. Pure/deterministic (no t/s claims
— partial-offload speed isn't reliably predictable; fit is what's computed).
- /api/hwfit/profiles endpoint returns the profiles + the model's trained
context limit, with loose name matching (strips org/ prefix, -GGUF suffix,
quant tag) so a local GGUF folder name resolves to its catalog entry.
- _buildServeCmd (llama.cpp) now emits --n-cpu-moe / --flash-attn /
--cache-type-k/v when set, with llama-cpp-python fallback equivalents. It
previously only set -ngl/-c, which is why it OOM'd or ran slow.
- Serve panel: profile chips that fill the fields on click, plus CPU-MoE / KV
Cache / Flash Attn fields. Context is clamped to the model's trained limit
(and an absolute 1M sanity ceiling) on type/blur/profile-load and at launch —
fixes a crash where a stale 256k/16M preset + quantized KV cache caused an
amdgpu ErrorDeviceLost.
Tests: tests/test_serve_profiles.py (7) — offload vs full-GPU fit, never exceed
VRAM, context cap, launchable flags, vision headroom, no-GPU empty.
Checks: py_compile + node --check pass; pytest test_serve_profiles + test_hwfit_amd
green; verified live on an RDNA4 box (gfx1200) — Balanced lands ~ncm18 q4 128k,
matching hand-tuning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Cookbook: make column-header sorting discoverable (incl. Newest)
Sorting in Cookbook is via clickable column headers (pewds' design), but the
headers had no visual cue that they're interactive — so sorting in general, and
the Newest sort on the Model header specifically, was undiscoverable.
- Style sortable headers as interactive: pointer cursor, hover underline, and
the active sort column bolded/highlighted. There was no CSS for
.hwfit-sortable / .hwfit-sort-active at all; this helps every existing sort,
not just Newest.
- The Model column header sorts by release_date (newest first), reusing the
existing header-click sort wiring and the "newest" SORT_KEY.
No new sort control — uses the existing column-header paradigm.
Checks: node --check passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Cookbook serve profiles: keep the on-disk file's quant fixed (don't propose Q6/Q2)
In the Serve tab the model is a specific GGUF file already on disk, so its quant
can't change — but the profiles were suggesting "Quality · Q6_K" / "Speed · Q2_K"
as if you could re-quantize it. That's meaningless when serving a fixed file.
- compute_serve_profiles gains serve_weights_gb / serve_quant. When set (SERVE
mode), the quant is locked to the file's and profiles differ only in the real
serving knobs — n_cpu_moe, KV-cache type, context. _weights_gb / _cpu_moe_for_budget
use the file's actual size instead of a quant-derived estimate. DOWNLOAD mode
(no override) still varies the quant to show download options.
- /api/hwfit/profiles accepts serve_weights_gb & serve_quant.
- The Serve panel parses the file's size (from m.size "20.6 GB") and quant (from
the repo/file name) and passes them, so profiles match what's actually served.
Result for a 20.6 GB Q4_K_M file: all three profiles stay Q4_K_M and differ by
KV/ctx/offload (Quality q8 KV 128k ncm21, Balanced q4 128k ncm17, Speed q4 32k
ncm15) — no nonsensical quant changes.
Tests: test_serve_mode_keeps_fixed_quant. Full serve-profile suite green (9).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Cookbook serve: Vision toggle (auto-find mmproj) + live VRAM/RAM-spillover monitor
Two serve-panel additions:
1. **Vision toggle.** A "Vision" checkbox that serves the model with its
multimodal projector so it can read images. The mmproj path is resolved at
runtime (find mmproj-*.gguf next to the model), so dropping an mmproj file in
the model folder makes the toggle just work; `--mmproj … --image-max-tokens
1024` (native) / `--clip_model_path` (llama-cpp-python) only when on + found.
2. **Live GPU-memory monitor.** A readout that polls /api/cookbook/gpus every 4s
while the panel is open and shows VRAM used/total/%, free, and — crucially on
a discrete card — **RAM spillover** (AMD gtt_used_mb), with a plain-language
health hint: green/healthy, amber/tight, red/"spilled to RAM — slow (raise
CPU MoE or lower context)". Surfaces gtt_used_mb from the gpus endpoint
(previously read for total only and discarded for 'used').
Lets you see at a glance whether a config fits VRAM (fast) or is paging to system
RAM over PCIe (slow) instead of guessing.
Checks: node --check + py_compile pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two small polish items in the Cookbook Serve panel.
Saved-config badge
The little count badge next to the Save button ("3 ▾" etc.) had a
generic "Saved launch configs" tooltip, so the number reads like a
notification dot. Make it spell out what it is and what clicking does:
"3 saved launch configs for <model> — click ▾ to load or delete"
(and "No saved launch configs for <model> yet — click Save to add
one" when empty). Tooltip stays in sync via _updateSavedToggleLabel
so save/delete updates both the count and the hint.
GPU chip on mixed-GPU boxes (#711)
The chip label was `${gpuCount}x ${gpu_name}`, where gpu_name is
just gpus[0].name — so a 4090 + 3060 reads as "2x RTX 4090". The
backend already emits gpu_groups (identical cards grouped, used by
the serve flow to pin CUDA_VISIBLE_DEVICES) and a per-card gpus[]
array, so use them:
- Label renders each homogeneous pool: "1× RTX 4090 + 1× RTX 3060".
Homogeneous setups keep the existing "2× RTX 4090" form.
- Tooltip lists each GPU with its index + VRAM, useful for picking
the right device when launching.
Refs #711.
Add a hashchange handler for #document-<id> so refresh / URL-bar nav opens the document, and replace the silent console.error in loadDocument with a user-facing toast.
Closes#560
* fix(stream): read 'reasoning' SSE field for vLLM 0.20.2 / NIM
vLLM 0.20.2 / NVIDIA NIM emit reasoning-parser output in the `reasoning` delta field; older builds use `reasoning_content`. stream_llm() read only the latter, so reasoning from models like Nemotron-3-Nano (--reasoning-parser) was silently dropped and never rendered. Accept either field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): keep reasoning_content only on the latest assistant turn
The agent loop echoed each round's reasoning back as `reasoning_content` on every assistant turn, assuming vendors ignore it. Nemotron's chat template re-injects ALL prior reasoning_content as <think> blocks, and the loop is trimmed only once (before it starts) — so reasoning accumulated unbounded across rounds, bloating context and feeding the model its own prior reasoning, which reinforced repetition/looping. Strip reasoning_content from earlier assistant turns so only the most recent round carries it (still satisfies DeepSeek's thinking-mode follow-up requirement).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent-ui): wrap each round's reasoning in its own <think> block
The streamed think-tag wrapper gated on whole-message substring checks (accumulated.includes('<think>')), which only ever wrapped ONE reasoning block per message. A multi-round agent response has a reasoning phase per round, so once round 1 closed its <think>...</think>, rounds 2+ reasoning was emitted unwrapped and leaked into the visible answer. Replace the substring checks with a stateful open/close flag that toggles per think/answer cycle, so each round's reasoning gets its own collapsible block. Single-turn chat is unchanged (one open, one close).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(stream): reasoning/reasoning_content delta surfaces as thinking chunk
Covers @pewdiepie-archdaemon's requested regression: a streamed {reasoning: ...} delta emits a thinking chunk while {content: ...} streams as normal content; plus the older reasoning_content field for backward compat. Mirrors the #591 scenario.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cost): treat dotless container hostnames as local (free)
getModelCost() substring-matches model names against a cloud price table, so a self-hosted 'nemotron'/'llama' model was billed at cloud rates. isLocalEndpoint() only recognized IPs / localhost / .local, not bare Docker service names (nim-nano, llamaswap), so the local-is-free guard missed them. A single-label hostname (no dot) can never be a public API -> treat as local.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cost): isLocalEndpoint classifies service names local, cloud FQDNs billable
Covers @pewdiepie-archdaemon's requested cases: llamaswap/nim-nano + localhost/private-IPs/.local => local (free); api.openai.com/openrouter.ai/etc => not local. Drives the real function via node --input-type=module (same approach as test_reply_recipients_js.py), skips when node is absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Update Styles.css
Small update to the styles that bothered me, i noticed in the window/modal for calendar when editing a day the time icons had a mask that overlapped the icon. I simply added 'background-image: none' prop to it/
* Importing files bug
I found a bug that wouldn't let me upload files in the library window during the documents tab, when a user selected a file, the code grabbed a reference to fileInput.files and immediately cleared the input value (fileInput.value = '') to allow for re-uploading the same file later. However, because fileInput.files is a live FileList tied directly to the DOM element, clearing the input inherently emptied our saved variable as well, resulting in lost file data.
Note this error might be browser specific as it worked fine on Zen/Firefox but failed on Edge and chrome
Fix use Array.From which copies the value into files instead of using refrences
The model-name detector treated every Qwen model as a Qwen3, falling
into the qwen3_xml parser:
if (n.includes('qwen3') && n.includes('coder')) return 'qwen3_coder';
if (n.includes('qwen')) return 'qwen3_xml'; // catches qwen2.5 too
qwen3_xml is the parser for Qwen3 reasoning/instruct models. Qwen2.5
(and Qwen2, Qwen1.5) ship with hermes-style tool calling, so the
qwen3_xml parser never recognises their tool calls — they leak through
as plain text in the assistant reply and the agent silently fails to
execute anything.
Reproduces with:
vllm serve Qwen/Qwen2.5-Coder-14B-Instruct-AWQ ... \
--enable-auto-tool-choice --tool-call-parser qwen3_xml
→ ask the agent to call any tool → JSON shows up in chat, no call runs.
Fix the ordering:
qwen3 + coder → qwen3_coder
qwen3 → qwen3_xml
qwen → hermes (Qwen2.5 / Qwen2 / Qwen1.5)
Verified against the model matrix:
Qwen2.5-Coder-14B-Instruct-AWQ → hermes
Qwen2.5-7B-Instruct → hermes
Qwen3-8B → qwen3_xml
Qwen3-32B → qwen3_xml
Qwen3-Coder-30B-A3B → qwen3_coder
Qwen2-72B-Instruct → hermes
Qwen1.5-7B-Chat → hermes
* feat: publish all configured email addresses for reply-all exclusion
* fix: exclude all of the user's own addresses from reply-all, not just the active one
* test: reply-all excludes all of the user's configured addresses
When the selected model fails before producing output, stream_llm_with_fallback
quietly switches to the next candidate and the reply is shown under the
originally selected model's name, so a misconfigured provider looks like it
works. (Concretely: a Bedrock gateway that 400s every Anthropic/Claude request
appears fine because another model silently answers under the Claude label.)
Emit a `fallback` SSE event ({selected_model, answered_by, reason}) the first
time a non-primary candidate produces output, forward it through the agent loop
and both chat-route paths, stamp the response metrics with the model that
actually answered, and show a notice + relabel the reply in the UI.
Tested: python -m pytest tests/test_llm_core_fallback.py (3 pass);
python -m py_compile src/llm_core.py src/agent_loop.py routes/chat_routes.py;
node --check static/js/chat.js.
* Fix test suite: ESM loading and stub isolation (refs #605)
Three targeted fixes to reduce suite failures from 9 → 1:
1. package.json: add "type": "module" so Node loads static/js/**
as ES modules. Fixes 7 tests in test_compare_js.py and
test_reply_recipients_js.py that fail with
"SyntaxError: Unexpected token 'export'".
2. test_null_owner_gates.py: add Base and ChatMessage to the
core.database stub. Without Base the scheduler test cannot
import at collection time; without ChatMessage core/__init__.py
fails mid-load when session_manager.py tries to import it,
leaving core partially initialised in sys.modules and poisoning
the auth manager migration test that runs later in the same file.
3. test_task_scheduler_session_delivery.py: skip gracefully when
core.database is stubbed (Base is a MagicMock) rather than
crashing. The test passes correctly when run in isolation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Scope ESM declaration to static/js/ and document isolation workaround
Per review feedback on #844:
1. Move "type": "module" from root package.json to static/js/package.json.
The root package.json had no type field (defaulted to CJS) and should
stay that way — vendored UMD bundles in static/lib/ use require() internally
and would break if Node ever tried to load them as ES modules. Node resolves
the nearest package.json, so adding it in static/js/ scopes the ESM
declaration to just the files the JS unit tests actually load
(compare/state.js, emailLibrary/replyRecipients.js).
2. Expand the module-level skip comment in test_task_scheduler_session_delivery
to document that it is a temporary isolation workaround, explain root cause
(test_null_owner_gates installs a module-level sys.modules stub with no
cleanup), record before/after suite numbers, and note the clean path
(refactor to fixture-scoped stub).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>