Merge origin/master into PR #5271

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Frank SongandPaperclip committed 2026-07-01 07:02:01 +08:00
commit fdeb37bbe2
7 files changed
+173 -15

No files matched your search

+3
View File
@@ -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
View File
@@ -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);}
-4
View File
@@ -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);
+16
View File
@@ -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"] == []
+15 -10
View File
@@ -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')"