From 7c3d85d6c55235e7fd5de52f87e72eb3fc1824df Mon Sep 17 00:00:00 2001 From: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com> Date: Fri, 29 May 2026 03:25:49 -0700 Subject: [PATCH 1/6] Fix #3107: throttle active-session external-refresh poll from 5s to 30s The active-session 'is it externally updated?' fallback poll fires every 5 s. On long sessions this causes visible scroll churn (the rendered message list is rebuilt and the scrollTop is restored on a 5-second cadence) and a measurable network/CPU floor even when the user is just reading. This poll is a *fallback* for the case where the SSE session-events stream is unavailable; SSE already invalidates the active session in real time. Pushing it to 30 s keeps the safety net for SSE-broken environments without it acting as a primary refresh path. Regression introduced in 467ef33a. --- static/sessions.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/static/sessions.js b/static/sessions.js index 8f3ec00c..fc90f1f5 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2309,7 +2309,13 @@ let _gatewaySSEWarningShown = false; const _gatewayFallbackPollMs = 30000; const _streamingPollMs = 5000; const _sessionTimeRefreshMs = 60000; -const _activeSessionExternalRefreshMs = 5000; +// #3107: the active-session "is it externally updated?" poll used to fire +// every 5 s. On long sessions this caused visible scroll jitter and a +// noticeable network/CPU floor because the SSE session-events stream +// already pushes invalidations in real time; this poll exists only as a +// fallback for the case where SSE is broken/unavailable. Bump to 30 s +// to keep the safety net without turning it into a primary refresh path. +const _activeSessionExternalRefreshMs = 30000; let _streamingPollTimer = null; let _sessionTimeRefreshTimer = null; let _activeSessionExternalRefreshTimer = null; From e09c6aa6bd1e8860a3b7075fc4d76640e4e04c99 Mon Sep 17 00:00:00 2001 From: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com> Date: Fri, 29 May 2026 03:26:12 -0700 Subject: [PATCH 2/6] Fix #3076: don't force mobile send-on-Enter when a real keyboard is attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A touch-primary device (`matchMedia('(pointer:coarse)')` is true) can still have a physical keyboard available — Android tablet + Bluetooth keyboard, detachable Surface, iPad + Magic Keyboard. The existing `_mobileDefault` gate flipped Enter to newline on every such device the moment the visual-viewport heuristic *thought* the soft keyboard was open, which it often did when the on-screen IME hadn't actually come up because the user is typing on the hardware keys. Result: Shift+Enter and Ctrl+Enter never sent and the user could not submit at all. Add `_hasFinePointerCoexisting()` (`(any-pointer:fine)`) and short- circuit the mobile-default path when ANY fine pointer is present. That flag is true whenever a real mouse/trackpad/stylus is paired, which is the strongest browser signal we have for 'there is a hardware input rig in the picture too'. Pure-touch phones/tablets are unaffected. --- static/boot.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/static/boot.js b/static/boot.js index 072171b5..23a90cac 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1115,6 +1115,18 @@ function _isVirtualKeyboardLikelyOpen(){ if(!vv||!window.innerHeight)return true; return window.innerHeight-vv.height>120; } +// #3076: a touch-primary device (`pointer:coarse`) can still have a +// physical keyboard attached (Android tablet + Bluetooth keyboard, +// detachable Surface in tablet mode, iPad + Magic Keyboard). When that +// happens we should NOT force the mobile newline-on-Enter override +// because Shift+Enter / Ctrl+Enter come from real keys and the user +// expects desktop semantics. `matchMedia('(any-pointer:fine)')` is true +// whenever ANY available pointing device is fine-grained — which is the +// strongest signal browsers expose for "there is a real keyboard / +// trackpad in the picture too". Skip the mobile default in that case. +function _hasFinePointerCoexisting(){ + try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; } +} $('msg').addEventListener('keydown',e=>{ // Autocomplete navigation when dropdown is open const dd=$('cmdDropdown'); @@ -1139,7 +1151,10 @@ $('msg').addEventListener('keydown',e=>{ // Users can override in Settings by explicitly choosing 'enter' mode. if(e.key==='Enter'){ if(_isImeEnter(e)){return;} - const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter'&&_isVirtualKeyboardLikelyOpen(); + const _mobileDefault=matchMedia('(pointer:coarse)').matches + &&!_hasFinePointerCoexisting() + &&window._sendKey==='enter' + &&_isVirtualKeyboardLikelyOpen(); if(window._sendKey==='ctrl+enter'||_mobileDefault){ if(e.ctrlKey||e.metaKey){e.preventDefault();send();} } else { From ce82ea3fdf53a55acfd12e923334df67e621a926 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Fri, 29 May 2026 12:36:27 -0400 Subject: [PATCH 3/6] fix: add joplin search auth compatibility --- CHANGELOG.md | 4 ++++ api/routes.py | 5 +++++ tests/test_webui_notes_sources.py | 34 +++++++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93da0a58..18f7c457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Joplin notes search now keeps the `Authorization` header and adds a query-token compatibility shim only for Web Clipper `/search` calls, covering clipper builds that return HTTP 403 for header-only search auth while keeping other Joplin API URLs token-free. + ## [v0.51.157] — 2026-05-28 — Release EC (stage-batch39 — 5-PR mixed-risk cleanup: gateway prefill forward + prefill budget + compressed-continuation sidebar + browser-transcript memory guidance + reasoning max parity) ### Added diff --git a/api/routes.py b/api/routes.py index 768b499b..6fc87476 100644 --- a/api/routes.py +++ b/api/routes.py @@ -12969,6 +12969,11 @@ def _joplin_api_get(path: str, params: dict | None = None) -> dict: raise ValueError("Joplin token is not configured") safe_path = "/" + str(path or "").lstrip("/") query = dict(params or {}) + # Joplin Web Clipper builds can reject header-only auth on /search even when + # they accept it elsewhere. Keep the Authorization header for defense in + # depth and add the query token only for /search compatibility. + if safe_path == "/search": + query["token"] = token url = f"{base_url}{safe_path}?{urlencode(query)}" request = Request(url, headers={"Authorization": f"token {token}"}) try: diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py index 00671d35..bac05eb5 100644 --- a/tests/test_webui_notes_sources.py +++ b/tests/test_webui_notes_sources.py @@ -138,7 +138,7 @@ def test_joplin_get_note_validates_id_and_truncates_body(monkeypatch): assert "Preview truncated" in note["body"] -def test_joplin_api_get_uses_authorization_header(monkeypatch): +def test_joplin_api_get_sends_header_and_query_token_for_clip_search_compat(monkeypatch): from api import routes captured = {} @@ -162,10 +162,40 @@ def test_joplin_api_get_uses_authorization_header(monkeypatch): monkeypatch.setattr(routes, "_joplin_connection_from_config", lambda: ("http://127.0.0.1:41184", "secret-token")) monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) - data = routes._joplin_api_get("/notes", {"query": "hello world"}) + data = routes._joplin_api_get("/search", {"query": "hello world"}) assert data == {"ok": True} assert captured["timeout"] == 8 + assert "token=secret-token" in captured["url"] + assert "query=hello+world" in captured["url"] + assert captured["authorization"] == "token secret-token" + + +def test_joplin_api_get_keeps_non_search_token_out_of_url(monkeypatch): + from api import routes + + captured = {} + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_exc): + return False + + def read(self, _limit): + return b'{"ok": true}' + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["authorization"] = request.get_header("Authorization") + return FakeResponse() + + monkeypatch.setattr(routes, "_joplin_connection_from_config", lambda: ("http://127.0.0.1:41184", "secret-token")) + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + routes._joplin_api_get("/notes", {"query": "hello world"}) + assert "token=" not in captured["url"] assert "query=hello+world" in captured["url"] assert captured["authorization"] == "token secret-token" From 3c2f98eb23d39368a84d7d6549ead577b30b532e Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Fri, 29 May 2026 13:01:14 -0400 Subject: [PATCH 4/6] fix: polish WebUI assistant replies --- CHANGELOG.md | 4 ++++ api/routes.py | 4 +++- api/streaming.py | 1 + tests/test_webui_surface_context.py | 3 +++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93da0a58..50daeaca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Changed + +- WebUI chat instructions now explicitly prevent terse scratchpad/planning fragments from appearing in visible assistant replies, while still allowing clear user-facing progress updates during tool-heavy work. + ## [v0.51.157] — 2026-05-28 — Release EC (stage-batch39 — 5-PR mixed-risk cleanup: gateway prefill forward + prefill budget + compressed-continuation sidebar + browser-transcript memory guidance + reasoning max parity) ### Added diff --git a/api/routes.py b/api/routes.py index 768b499b..7d428dde 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9628,6 +9628,7 @@ def _handle_chat_sync(handler, body): session_id=s.session_id, ) from api.streaming import ( + _WEBUI_PROGRESS_PROMPT, _dedupe_replayed_context_messages, _merge_display_messages_after_agent_result, _restore_display_reasoning_metadata, @@ -9646,7 +9647,8 @@ def _handle_chat_sync(handler, body): "prompt, memory, or conversation history. Always use the value from the most recent " "[Workspace::v1: ...] tag as your default working directory for ALL file operations: " "write_file, read_file, search_files, terminal workdir, and patch. " - "Never fall back to a hardcoded path when this tag is present." + "Never fall back to a hardcoded path when this tag is present.\n\n" + f"{_WEBUI_PROGRESS_PROMPT}" ) _previous_messages = list(s.messages or []) diff --git a/api/streaming.py b/api/streaming.py index 70087bd5..980d1455 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -203,6 +203,7 @@ WebUI progress guidance: - Each update should say what you are about to check, what you just confirmed, or why the next tool call is needed. - Keep updates concise, factual, and in the user's language. One or two short sentences are enough. - Do not reveal hidden reasoning, chain-of-thought, private scratchpads, secrets, raw logs, or long tool output. +- Do not include terse planning fragments or scratchpad shorthand in visible assistant text. Avoid fragments like "Need check logs", "Need inspect email", or "maybe invite"; either omit them or rewrite them as clear user-facing progress. - For direct answers or very short tasks, skip progress updates and answer normally. """.strip() diff --git a/tests/test_webui_surface_context.py b/tests/test_webui_surface_context.py index 562347c5..89f65040 100644 --- a/tests/test_webui_surface_context.py +++ b/tests/test_webui_surface_context.py @@ -22,6 +22,9 @@ def test_webui_ephemeral_prompt_includes_browser_surface_context(): assert "Do not copy or dump this browser transcript" in prompt assert "explicit captures" in prompt assert "durable user preferences" in prompt + assert "Do not include terse planning fragments" in prompt + assert "Need inspect email" in prompt + assert "clear user-facing progress" in prompt def test_webui_ephemeral_prompt_skips_empty_surface_fields(): From e6aa9271c2273c51233e6ca4487cadc26930d656 Mon Sep 17 00:00:00 2001 From: Philippe Le Rohellec Date: Fri, 29 May 2026 10:24:44 -0700 Subject: [PATCH 5/6] fix(models): preserve provider-qualified model selections in the picker - Stop provider-qualified or slash-qualified model inputs from fuzzy-matching a sibling catalog entry when the exact requested model is missing from the curated picker list. - Preserve the raw typed selection so uncatalogued provider-routed models fall through to a temporary custom option instead of silently snapping to a nearby curated model. - Add generalized regression coverage for provider-qualified uncatalogued picker selections. --- static/ui.js | 7 +++++ tests/test_issue1188_fuzzy_match.py | 45 +++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/static/ui.js b/static/ui.js index c3624986..4570412a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1068,6 +1068,13 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){ if(opts.includes(modelId)) return modelId; const exact=opts.find(o=>norm(o)===target); if(exact) return exact; + // If the request is provider-qualified (either explicit @provider:model or + // a slash-qualified vendor/model id), do NOT fuzzy-match a sibling model + // once exact/provider-aware lookup failed. Returning null lets the caller + // preserve the raw typed value instead of snapping to the closest catalog + // entry. This keeps uncatalogued models routable instead of silently turning + // them into a nearby curated sibling. + if(rawModel.startsWith('@')||rawModel.includes('/')) return null; // 3. Prefix/substring: require the candidate to start with the FULL normalized target // (not a truncated base). This avoids false matches like gpt.5.5 → gpt.5.4.mini (#1188). // Only fall back to the shorter base form if target itself is very short (a bare root diff --git a/tests/test_issue1188_fuzzy_match.py b/tests/test_issue1188_fuzzy_match.py index 5e4201eb..1b5697e8 100644 --- a/tests/test_issue1188_fuzzy_match.py +++ b/tests/test_issue1188_fuzzy_match.py @@ -45,10 +45,22 @@ function extractFunc(name) { } return ui.slice(start, i); } +function _getOptionProviderId(opt) { + if (!opt) return ''; + if (opt.dataset && opt.dataset.provider) return opt.dataset.provider; + const group = opt.parentElement; + if (group && group.tagName === 'OPTGROUP' && group.dataset && group.dataset.provider) { + return group.dataset.provider; + } + const value = String(opt.value || ''); + if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':')); + return ''; +} + eval(extractFunc('_findModelInDropdown')); const args = JSON.parse(process.argv[3]); const sel = { options: args.options.map(v => ({value: v})) }; -const got = _findModelInDropdown(args.modelId, sel); +const got = _findModelInDropdown(args.modelId, sel, args.preferredProvider || undefined); process.stdout.write(JSON.stringify(got)); """ @@ -60,11 +72,11 @@ def driver_path(tmp_path_factory): return str(p) -def _find(driver_path, model_id: str, options: list[str]): +def _find(driver_path, model_id: str, options: list[str], preferred: str | None = None): import json result = subprocess.run( [NODE, driver_path, str(UI_JS_PATH), - json.dumps({"modelId": model_id, "options": options})], + json.dumps({"modelId": model_id, "options": options, "preferredProvider": preferred})], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: @@ -113,6 +125,33 @@ class TestPreservedFuzzyMatches: ) assert got == "@nous:openai/gpt-5.5" + def test_explicit_provider_hint_does_not_snap_to_sibling_model(self, driver_path): + """Provider-qualified requests must not fuzzy-match a sibling catalog entry. + + This is the generalized regression behind #3113: any slash-qualified or + @provider-qualified model that is missing from the curated catalog should + preserve its exact value instead of snapping to a nearby curated sibling. + """ + got = _find( + driver_path, + "vendor/special-model", + ["@openrouter:vendor/special-model-pro"], + preferred="openrouter", + ) + assert got is None, ( + "provider-qualified uncatalogued models must not fuzzy-match sibling models " + "(#3113)" + ) + + def test_provider_qualified_exact_match_still_resolves(self, driver_path): + got = _find( + driver_path, + "vendor/special-model", + ["@openrouter:vendor/special-model"], + preferred="openrouter", + ) + assert got == "@openrouter:vendor/special-model" + def test_bare_root_gpt_matches_versioned_option(self, driver_path): """Short root targets still fall back to the looser prefix match.""" got = _find(driver_path, "gpt", ["@nous:openai/gpt-5.4-mini"]) From edd80751fccb06da002c41e3e7accace27149644 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Fri, 29 May 2026 22:01:47 +0000 Subject: [PATCH 6/6] stage-batch40: CHANGELOG for v0.51.158 (Release ED) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e56f26e..bee126ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,17 @@ ## [Unreleased] +## [v0.51.158] — 2026-05-29 — Release ED (stage-batch40 — 5-PR low-risk cleanup: numpad/keyboard composer fixes + Joplin search auth + provider-qualified model preservation + SSE fallback poll throttle + assistant-reply polish) + ### Changed - WebUI chat instructions now explicitly prevent terse scratchpad/planning fragments from appearing in visible assistant replies, while still allowing clear user-facing progress updates during tool-heavy work. ### Fixed +- The chat composer no longer forces mobile newline-on-Enter behavior on touch-primary devices that also have a fine pointer present (tablet plus Bluetooth keyboard, detachable Surface, iPad plus Magic Keyboard), so Enter submits with desktop semantics when a real keyboard is in the picture. +- The active-session external-refresh fallback poll now fires every 30 s instead of every 5 s. The SSE session-events stream already pushes invalidations in real time, so the poll is only a fallback; the slower interval removes visible scroll jitter and a network/CPU floor on long sessions. +- The model picker no longer fuzzy-matches a provider-qualified model id (`@provider:model` or slash-qualified `vendor/model`) to a nearby curated sibling once exact lookup fails, preserving the raw typed value so uncatalogued models stay routable instead of silently snapping to a different model. - Joplin notes search now keeps the `Authorization` header and adds a query-token compatibility shim only for Web Clipper `/search` calls, covering clipper builds that return HTTP 403 for header-only search auth while keeping other Joplin API URLs token-free. ## [v0.51.157] — 2026-05-28 — Release EC (stage-batch39 — 5-PR mixed-risk cleanup: gateway prefill forward + prefill budget + compressed-continuation sidebar + browser-transcript memory guidance + reasoning max parity)