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) diff --git a/static/boot.js b/static/boot.js index 01e988ce..13e0cd05 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2845,7 +2845,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/static/panels.js b/static/panels.js index 96dfb735..0a4141ec 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/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 new file mode 100644 index 00000000..befc3aba --- /dev/null +++ b/tests/test_issue5231_default_kanban_dispatch.py @@ -0,0 +1,118 @@ +import json +import shutil +import subprocess +import tempfile +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") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +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_function(PANELS, function_name, prefix="async function") + 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..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") @@ -526,6 +528,15 @@ 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_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." + ) + + def test_kanban_board_has_native_css_classes(): for selector in ( ".kanban-board", @@ -1107,17 +1118,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 +1142,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"]] 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')"