From 4b33458c4b56b5485502c7319d142cee2cda768f Mon Sep 17 00:00:00 2001 From: santastabber <197905764+santastabber@users.noreply.github.com> Date: Wed, 1 Jul 2026 05:29:34 +1000 Subject: [PATCH 1/4] fix(pwa): defer new-chat model hydration --- static/boot.js | 9 ++++++++- tests/test_new_chat_default_model_frontend.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/static/boot.js b/static/boot.js index a1b9074c..3b44b497 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2764,7 +2764,14 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; if(pwaLaunchAction==='new-chat'){ try{ await newSession(true); - if(S.session) await _startBootModelDropdown(); + // New-chat PWA launches need the empty conversation visible immediately. + // Boot model hydration can take several seconds when /api/models falls + // into a cold provider-catalog rebuild; it is already safe to finish in + // the background because newSession() posted the configured default and + // rendered the session's authoritative model/provider. + if(S.session){ + try{Promise.resolve(_startBootModelDropdown()).catch(()=>{});}catch(_){} + } S._bootReady=true; syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();return; }catch(e){console.warn('[pwa] new-chat launch action failed', e);} diff --git a/tests/test_new_chat_default_model_frontend.py b/tests/test_new_chat_default_model_frontend.py index 6506447b..da960125 100644 --- a/tests/test_new_chat_default_model_frontend.py +++ b/tests/test_new_chat_default_model_frontend.py @@ -68,6 +68,19 @@ def test_hard_refresh_hydrates_saved_session_model_before_revealing_model_chip() ) +def test_pwa_new_chat_launch_does_not_block_first_paint_on_model_catalog(): + boot_js = Path("static/boot.js").read_text(encoding="utf-8") + launch_marker = "if(pwaLaunchAction==='new-chat'){" + assert launch_marker in boot_js + launch_branch = boot_js[boot_js.index(launch_marker) : boot_js.index("const savedLocal=localStorage.getItem", boot_js.index(launch_marker))] + assert "await newSession(true);" in launch_branch + assert "await _startBootModelDropdown();" not in launch_branch + assert "Promise.resolve(_startBootModelDropdown()).catch(()=>{})" in launch_branch + assert launch_branch.index("Promise.resolve(_startBootModelDropdown()).catch(()=>{})") < launch_branch.index("S._bootReady=true;"), ( + "PWA new-chat launches should kick model hydration in the background before revealing the empty chat" + ) + + def test_hard_refresh_injects_missing_active_session_model_option(): boot_js = Path("static/boot.js").read_text(encoding="utf-8") marker = "if(!applied&&sessionModelState&&typeof _ensureModelOptionInDropdown==='function')" From e8631b808f651c14710cbfae8aa5a163a2b29668 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 30 Jun 2026 15:49:09 -0400 Subject: [PATCH 2/4] fix(#5231): unblock Run dispatcher on the default Kanban board --- static/panels.js | 4 - .../test_issue5231_default_kanban_dispatch.py | 134 ++++++++++++++++++ tests/test_kanban_ui_static.py | 43 ++++-- 3 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 tests/test_issue5231_default_kanban_dispatch.py diff --git a/static/panels.js b/static/panels.js index 2bb7e263..9bcf003f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2380,10 +2380,6 @@ async function runKanbanDispatcher(){ // Confirmation dialog first because this actually consumes API budget on // each spawned worker. Result toast surfaces what happened so users see // the dispatcher actually doing work. - if (!_kanbanCurrentBoard) { - showToast(t('kanban_unavailable') || 'Kanban unavailable', 'error'); - return; - } _kanbanIsDispatching = true; _setKanbanDispatcherButtonsDisabled(true); diff --git a/tests/test_issue5231_default_kanban_dispatch.py b/tests/test_issue5231_default_kanban_dispatch.py new file mode 100644 index 00000000..238f74a7 --- /dev/null +++ b/tests/test_issue5231_default_kanban_dispatch.py @@ -0,0 +1,134 @@ +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _extract_async_function(js: str, name: str) -> str: + marker = f"async function {name}(" + start = js.find(marker) + assert start >= 0, f"{name} function not found in static/panels.js" + brace = js.find("{", start) + assert brace >= 0, f"{name} opening brace not found" + depth = 1 + i = brace + 1 + while i < len(js) and depth > 0: + if js[i] == "{": + depth += 1 + elif js[i] == "}": + depth -= 1 + i += 1 + assert depth == 0, f"{name} function braces unbalanced" + return js[start:i] + + +def _run_node(js: str) -> dict: + with tempfile.NamedTemporaryFile("w", suffix=".cjs", encoding="utf-8", dir=ROOT, delete=False) as script: + script.write(js) + script_path = Path(script.name) + try: + proc = subprocess.run( + [NODE, str(script_path)], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + finally: + script_path.unlink(missing_ok=True) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip()) + return json.loads(proc.stdout.strip()) + + +def _run_dispatcher_case(function_name: str, current_board, confirm: bool) -> dict: + fn_source = _extract_async_function(PANELS, function_name) + source = f""" +const fnSource = {json.dumps(fn_source)}; +eval(fnSource); + +let _kanbanCurrentBoard = {json.dumps(current_board)}; +let _kanbanIsDispatching = false; +const apiCalls = []; +const confirmCalls = []; +const toastCalls = []; +const showConfirmDialog = async (opts) => {{ + confirmCalls.push(opts); + return {str(confirm).lower()}; +}}; +const api = async (path, opts = {{}}) => {{ + apiCalls.push({{ path, method: opts.method || "GET", body: ("body" in opts ? opts.body : null) }}); + return {{ + spawned: 1, + skipped_unassigned: 0, + skipped_nonspawnable: 0, + promoted: 0, + auto_blocked: 0, + }}; +}}; +const showToast = (...args) => {{ + toastCalls.push(args); +}}; +const t = (key) => key; +const _setKanbanDispatcherButtonsDisabled = () => {{}}; +const loadKanban = async () => {{}}; +const _kanbanFormatDispatchResult = () => "ok"; +const document = {{ + querySelectorAll: () => [], +}}; + +(async () => {{ + try {{ + await {function_name}(); + console.log(JSON.stringify({{ + apiCalls, + confirmCalls, + toastCalls, + ok: true, + }})); + }} catch (err) {{ + console.error(err && err.stack ? err.stack : String(err)); + process.exit(1); + }} +}})(); +""" + return _run_node(source) + + +def test_default_board_real_dispatch_sends_boardless_post_when_confirmed(): + result = _run_dispatcher_case("runKanbanDispatcher", None, True) + assert result["confirmCalls"], "expected confirmation dialog" + assert result["apiCalls"] == [ + {"path": "/api/kanban/dispatch?max=8", "method": "POST", "body": None}, + ] + + +def test_default_board_preview_dispatch_stays_dry_run_and_boardless(): + result = _run_dispatcher_case("nudgeKanbanDispatcher", None, True) + assert result["confirmCalls"] == [] + assert result["apiCalls"] == [ + {"path": "/api/kanban/dispatch?dry_run=1&max=8", "method": "POST", "body": None}, + ] + + +def test_named_board_real_dispatch_preserves_board_parameter(): + result = _run_dispatcher_case("runKanbanDispatcher", "qa", True) + assert result["apiCalls"] == [ + {"path": "/api/kanban/dispatch?max=8&board=qa", "method": "POST", "body": None}, + ] + + +def test_confirmation_decline_prevents_real_dispatch_post(): + result = _run_dispatcher_case("runKanbanDispatcher", None, False) + assert result["confirmCalls"], "expected confirmation attempt before dispatch" + assert result["apiCalls"] == [] diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index 2478875a..f45c68d7 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -11,6 +11,24 @@ COMPACT_PANELS = re.sub(r"\s+", "", PANELS) COMPACT_STYLE = re.sub(r"\s+", "", STYLE) +def _extract_function(js: str, name: str) -> str: + marker = f"function {name}(" + start = js.find(marker) + assert start >= 0, f"{name} function not found in static/panels.js" + brace = js.find("{", start) + assert brace >= 0, f"{name} opening brace not found" + depth = 1 + i = brace + 1 + while i < len(js) and depth > 0: + if js[i] == "{": + depth += 1 + elif js[i] == "}": + depth -= 1 + i += 1 + assert depth == 0, f"{name} function braces unbalanced" + return js[start:i] + + def _locale_blocks_with_body(i18n_text: str): locale_blocks = re.findall( r"\n\s*(?:'(?P[a-z]{2}(?:-[A-Z][A-Za-z]+)?)'|(?P[a-z]{2}(?:-[A-Z]{2})?))\s*:\s*\{(.*?)\n\s*\},", @@ -526,6 +544,17 @@ def test_kanban_dispatcher_inflight_guard_prevents_double_click_toast_confusion( assert 'btnKanbanPreviewDispatcher' in INDEX +def test_kanban_dispatcher_no_longer_blocks_default_board(): + """Pin removal of the previous null-board guard in runKanbanDispatcher().""" + run_match = re.search(r"async function runKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL) + assert run_match, "runKanbanDispatcher() not found" + run_body = run_match.group(1) + assert "if (!_kanbanCurrentBoard)" not in run_body, ( + "runKanbanDispatcher() must not block when _kanbanCurrentBoard is null; " + "default board should dispatch through a board-less path." + ) + + def test_kanban_board_has_native_css_classes(): for selector in ( ".kanban-board", @@ -1107,17 +1136,11 @@ def test_kanban_board_color_is_validated_against_css_injection(): """ import json import subprocess + fn_source = _extract_function(PANELS, "_kanbanSafeColor") script = """ -const fs = require('fs'); -const src = fs.readFileSync('static/panels.js', 'utf8'); -const start = src.indexOf('function _kanbanSafeColor'); -if (start < 0) { console.error('_kanbanSafeColor missing'); process.exit(2); } -// Grab the function body up to and including the closing `}` line. -const tail = src.slice(start); -const end = tail.indexOf('\\n}\\n') + 2; -const fn = tail.slice(0, end); +const fnSource = __FN__; const ctx = {}; -new Function('out', fn + '; out.fn = _kanbanSafeColor;')(ctx); +new Function('out', fnSource + '; out.fn = _kanbanSafeColor;')(ctx); const cases = [ ['#fff', '#fff'], ['#3b82f6', '#3b82f6'], @@ -1137,7 +1160,7 @@ const results = cases.map(([input, expected]) => ({ input, expected, actual: ctx.fn(input) })); console.log(JSON.stringify(results)); -""" +""".replace("__FN__", json.dumps(fn_source)) result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) results = json.loads(result.stdout) failures = [r for r in results if r["actual"] != r["expected"]] From 027ad18834ab0d4323b1a7f4541d04c07d27b207 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 30 Jun 2026 16:06:39 -0400 Subject: [PATCH 3/4] test(#5231): harden JS source extraction coverage --- tests/js_source_extract.py | 16 +++++++++++ .../test_issue5231_default_kanban_dispatch.py | 22 ++------------- tests/test_kanban_ui_static.py | 28 ++++--------------- 3 files changed, 24 insertions(+), 42 deletions(-) create mode 100644 tests/js_source_extract.py diff --git a/tests/js_source_extract.py b/tests/js_source_extract.py new file mode 100644 index 00000000..110e0eb8 --- /dev/null +++ b/tests/js_source_extract.py @@ -0,0 +1,16 @@ +def extract_function(js: str, name: str, prefix: str = "function") -> str: + marker = f"{prefix} {name}(" + start = js.find(marker) + assert start >= 0, f"{name} function not found in static/panels.js" + brace = js.find("{", start) + assert brace >= 0, f"{name} opening brace not found" + depth = 1 + i = brace + 1 + while i < len(js) and depth > 0: + if js[i] == "{": + depth += 1 + elif js[i] == "}": + depth -= 1 + i += 1 + assert depth == 0, f"{name} function braces unbalanced" + return js[start:i] diff --git a/tests/test_issue5231_default_kanban_dispatch.py b/tests/test_issue5231_default_kanban_dispatch.py index 238f74a7..befc3aba 100644 --- a/tests/test_issue5231_default_kanban_dispatch.py +++ b/tests/test_issue5231_default_kanban_dispatch.py @@ -6,6 +6,8 @@ from pathlib import Path import pytest +from tests.js_source_extract import extract_function + ROOT = Path(__file__).resolve().parents[1] PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") @@ -14,24 +16,6 @@ NODE = shutil.which("node") pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") -def _extract_async_function(js: str, name: str) -> str: - marker = f"async function {name}(" - start = js.find(marker) - assert start >= 0, f"{name} function not found in static/panels.js" - brace = js.find("{", start) - assert brace >= 0, f"{name} opening brace not found" - depth = 1 - i = brace + 1 - while i < len(js) and depth > 0: - if js[i] == "{": - depth += 1 - elif js[i] == "}": - depth -= 1 - i += 1 - assert depth == 0, f"{name} function braces unbalanced" - return js[start:i] - - def _run_node(js: str) -> dict: with tempfile.NamedTemporaryFile("w", suffix=".cjs", encoding="utf-8", dir=ROOT, delete=False) as script: script.write(js) @@ -52,7 +36,7 @@ def _run_node(js: str) -> dict: def _run_dispatcher_case(function_name: str, current_board, confirm: bool) -> dict: - fn_source = _extract_async_function(PANELS, function_name) + fn_source = extract_function(PANELS, function_name, prefix="async function") source = f""" const fnSource = {json.dumps(fn_source)}; eval(fnSource); diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index f45c68d7..bf080210 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -1,6 +1,8 @@ from pathlib import Path import re +from tests.js_source_extract import extract_function + ROOT = Path(__file__).resolve().parents[1] INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8") PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") @@ -11,24 +13,6 @@ COMPACT_PANELS = re.sub(r"\s+", "", PANELS) COMPACT_STYLE = re.sub(r"\s+", "", STYLE) -def _extract_function(js: str, name: str) -> str: - marker = f"function {name}(" - start = js.find(marker) - assert start >= 0, f"{name} function not found in static/panels.js" - brace = js.find("{", start) - assert brace >= 0, f"{name} opening brace not found" - depth = 1 - i = brace + 1 - while i < len(js) and depth > 0: - if js[i] == "{": - depth += 1 - elif js[i] == "}": - depth -= 1 - i += 1 - assert depth == 0, f"{name} function braces unbalanced" - return js[start:i] - - def _locale_blocks_with_body(i18n_text: str): locale_blocks = re.findall( r"\n\s*(?:'(?P[a-z]{2}(?:-[A-Z][A-Za-z]+)?)'|(?P[a-z]{2}(?:-[A-Z]{2})?))\s*:\s*\{(.*?)\n\s*\},", @@ -546,10 +530,8 @@ def test_kanban_dispatcher_inflight_guard_prevents_double_click_toast_confusion( def test_kanban_dispatcher_no_longer_blocks_default_board(): """Pin removal of the previous null-board guard in runKanbanDispatcher().""" - run_match = re.search(r"async function runKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL) - assert run_match, "runKanbanDispatcher() not found" - run_body = run_match.group(1) - assert "if (!_kanbanCurrentBoard)" not in run_body, ( + run_source = extract_function(PANELS, "runKanbanDispatcher", prefix="async function") + assert "if (!_kanbanCurrentBoard)" not in run_source, ( "runKanbanDispatcher() must not block when _kanbanCurrentBoard is null; " "default board should dispatch through a board-less path." ) @@ -1136,7 +1118,7 @@ def test_kanban_board_color_is_validated_against_css_injection(): """ import json import subprocess - fn_source = _extract_function(PANELS, "_kanbanSafeColor") + fn_source = extract_function(PANELS, "_kanbanSafeColor") script = """ const fnSource = __FN__; const ctx = {}; From a616163523b9c76af1f21f489be35cb342e4ebeb Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 30 Jun 2026 22:45:35 +0000 Subject: [PATCH 4/4] docs(changelog): default-Kanban dispatch fix (#5289) + PWA new-chat hydration defer (#5287) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f2ac3d..da8118da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ ### Fixed +- **The Run dispatcher now works on the default Kanban board.** The default board is stored as `null`, and the dispatcher's board check treated that as "no board," so the Run button silently no-opped on the default board. It now resolves the default board correctly and dispatches. Thanks @rodboev. (#5289, fixes #5231) +- **PWA / deep-link new-chat launches show the empty conversation immediately.** The boot model-dropdown hydration is deferred to the background instead of blocking the first paint, so a new-chat launch from the installed PWA or a deep link renders the composer right away rather than waiting on the model list. Thanks @santastabber. (#5287) + - **Two new opt-in appearance skins: Neon Soft and Neon Paint.** Both are CSS-only, namespaced under `[data-skin]`, registered in server-side skin persistence, and selectable from Settings → Appearance — they change nothing unless you pick them. Neon Soft is a muted purple/violet neon; Neon Paint a bolder magenta + cyan neon. Thanks @savagebread. (#4738) - **Opt-in Shift+Enter send-key mode.** A new send-key option mirrors the existing Ctrl+Enter mode: when set to "shift+enter", Shift+Enter sends the message and a plain Enter inserts a newline; the default "enter" behavior (Enter sends, Shift+Enter newline) is unchanged when the option is off. IME composition, numpad Enter, and the command-dropdown are all handled, with no double-send. Thanks @futureworld678. (#5005)