Merge origin/master into PR #5271
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
fdeb37bbe2
7 files changed
+173
-15
No files matched your search
@@ -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)
|
||||
|
||||
+8
-1
@@ -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);}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
@@ -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"] == []
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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')"
|
||||
|
||||
Reference in new issue
Block a user