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>
In Compare each pane renders into a sandboxed <iframe>. A file dropped on
a pane was handled by the iframe (browser default), so the browser loaded
the file *inside* the pane — appearing 'behind' the app — instead of
attaching it. The existing #chat-container drop handler never sees the
event because drag events don't bubble out of an iframe.
While a file drag is active in Compare, raise a single full-window drop
shield above the panes/iframes so the drop lands on the parent document,
then route the files into the shared composer (the same pending-files
pipeline the file picker and paste already use). Scoped to Compare via the
.compare-active class, so normal chat and the tool dropzones (gallery, RAG,
document editor, …) are unaffected.
Verified with a headless-Chromium integration test: synthetic file
dragover raises the shield, drop attaches the file to the composer, and
non-Compare mode is unaffected. Also ran node --check static/app.js.
* Ignore AltGr keystrokes in Ctrl+Alt keyboard shortcuts
Browsers report AltGr (right Alt on AZERTY/QWERTZ and most non-US
layouts, used to type @ # { } [ ] | \ and the euro sign) as
ctrlKey+altKey. The default keybinds map destructive actions to
Ctrl+Alt+<letter> (delete_session, new_session, incognito,
open_calendar), so a non-US user typing a special character could
silently fire them.
Guard the shortcut matcher, the editor keydown handler, and the rebind
capture with getModifierState('AltGraph'), which is true for AltGr but
false for a genuine left Ctrl+Alt. macOS is excluded: there the Option
key legitimately sets AltGraph and there is no AltGr/Ctrl+Alt collision
to guard against, so the guard would otherwise break Ctrl+Option /
Cmd+Option shortcuts (notably in Firefox).
The detection lives in one place — isAltGrEvent / IS_MAC in
static/js/platform.js — and all three call sites route through it, so the
guards can't drift apart.
The editor handler only skips the Ctrl+Alt chord block, so layout
shortcuts reachable via AltGr (e.g. [ ] brush size = AltGr+5/+8 on
AZERTY) keep working.
* Require Ctrl+Alt for the AltGr guard and consolidate keybind test marks
isAltGrEvent now also checks ctrlKey+altKey so it only suppresses the
"AltGr reported as Ctrl+Alt" collision; an event asserting AltGraph on
its own (a Linux ISO_Level3_Shift layout, a stray modifier) is left
alone. Pin it with test_isaltgr_false_when_altgraph_set_but_not_ctrl_alt.
Collapse the 12 per-test node skipif marks into one module-level
pytestmark, and note in platform.js why IS_MAC intentionally covers
iPad/iPhone and mirrors the isMac checks in calendar.js / sessions.js.
The Cookbook Scan/Download (hwfit) table gave the Fit column key:'score', so
clicking the Fit header sorted by score instead of by fit. Give the Fit column
its own 'fit' sort key, add a matching option to the #hwfit-sort select, and
rank fit_level (perfect > good > marginal > too_tight > no_fit) in the
client-side sort. Default puts the best fit first; clicking again reverses it.
Score still sorts by score.
Closes#842
`mdToHtml` deliberately stashes literal <details> blocks and <a> tags from
the source text *before* the global HTML-escape pass and restores them
verbatim into the string callers assign to `innerHTML` (e.g. chatRenderer's
`b.innerHTML = ...processWithThinking(text)`). Nothing scrubbed those
fragments, so message/agent content containing
`<details><img src=x onerror=...></details>` or
`<a href="javascript:..." onmouseover=...>` executed arbitrary script in
the authenticated page.
Route both stashed fragments through `sanitizeAllowedHtml()`, which parses
them in an inert <template> (no resource loads, no script execution),
removes script-capable elements, and strips event-handler attributes plus
javascript:/vbscript:/data: URL schemes. Hardening details:
- Compare tag names case-insensitively and drop the SVG/MathML foreign-
content roots. An SVG-namespaced <script> has the lower-case tagName
'script', so an HTML-only upper-case check would miss it — a real bypass.
- Sanitize to a fixpoint (re-parse + re-clean until stable) to blunt
mutation-XSS, where re-serializing/re-parsing reshapes the tree.
Benign anchors and <details> blocks are preserved unchanged.
Verified under jsdom against the obvious vectors plus mutation-XSS probes
(svg/math-namespaced <script>, foreignObject, ns-confusion, comment
breakout, template smuggling): no script/iframe element, event handler, or
javascript:/data: URL survives, and benign markup is kept.
Co-authored-by: Claude <noreply@anthropic.com>
The email reader folds quoted history into <details> summaries via
`_foldSummary()` (static/js/emailLibrary/signatureFold.js), which builds a
sender/date "meta" chip into the summary HTML and assigns it to innerHTML.
The server-side thread parser (`_extract_quote_meta`,
src/email_thread_parser.py) strips tags but then un-escapes HTML entities
and preserves `<...>` patterns, and that raw meta reaches `_foldSummary`
unescaped via `_renderTurnsFromServer` (`t.meta`) — so an inbound email
whose quoted attribution contains `From: <img src=x onerror=...>`
runs script when the victim merely opens the message (stored XSS).
Make `_foldSummary` the single escaping chokepoint: escape `primary` and
`subMeta` with the module's existing `_esc`. The client-side
`_extractQuoteMeta` previously pre-escaped its output, and every consumer
of it routes through `_foldSummary`, so drop that now-redundant escaping to
avoid double-encoding (e.g. "Ben & Jerry" -> "Ben &amp; Jerry").
Verified (jsdom): server-raw and client-extracted malicious metas yield 0
live elements and 0 event-handler attributes; benign "Ben & Jerry" renders
single-escaped.
Co-authored-by: Claude <noreply@anthropic.com>
First incremental pass at issue #86, focused on the universal entry
points and primary navigation. All changes verified in-browser with the
axe-core engine (0 violations on the surfaces below) plus manual keyboard
testing, on both desktop (1280px) and mobile (390px).
Login / first-run setup (static/login.html)
- Add a real <h1>, wrap content in <main> + <footer> landmarks.
- Mark the decorative boat SVG aria-hidden.
- Errors now use role="alert" so screen readers announce them.
- "Remember me" checkbox is keyboard-focusable (was display:none) with an
accessible name and a focus ring; dynamic 2FA field gets a linked label.
- Darken the brand-red submit button so white text clears WCAG AA 4.5:1
(was ~3.2:1); add visible :focus-visible rings.
App shell (static/index.html, static/style.css)
- Remove invalid role="region" from the <main> chat container (it was
overriding the implicit main landmark).
- Add a persistent, visually-hidden <h1> inside <main> so the page always
exposes one logical level-1 heading — works even on mobile where the
sidebar (with the visible brand) is hidden off-canvas.
- Add a reusable .a11y-visually-hidden utility.
- Raise chat-title, model-picker, settings-helper and notes text contrast
above 4.5:1 (were 2.8-3.9:1).
Keyboard nav + dialogs (static/js/a11y.js - new)
- Make the click-only <div> sidebar navigation (New Chat, Search, Brain,
Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes,
Tasks, Theme, account) focusable and Enter/Space-activatable, announced
as buttons (skipping role=button where a nested control would create a
nested-interactive violation). Visible focus ring reused from existing
.list-item:focus-visible.
- Upgrade modals (.modal-content and the docked .notes-pane) to labelled
role="dialog" + aria-modal, and normalise their title to heading level 2
so heading order stays valid. A MutationObserver covers runtime-rendered
rows and modals.
Decorative background canvases (static/js/theme.js)
- Mark all 7 bg-effect canvases aria-hidden.
Notes & Tasks (static/js/notes.js, static/js/tasks.js)
- Label the icon-only Note/To-do toggle pills (fixes a critical
button-name issue) and track aria-pressed state.
- Improve Notes header-button + empty-state contrast.
- Give the Tasks sort <select> an accessible name (fixes a critical
select-name issue).
Remaining data-dense tool modals (Tasks cards, Calendar, Gallery, Email,
Cookbook, Compare, Deep Research) still have muted-text contrast to polish
and are the next incremental step, per the issue's own guidance.