fix(desktop): canvas-aware design routing and honest parallel-agents semantics

A pure-Chinese continuation like continuing the remaining pages never
matched the ASCII keyword gate in loop_enabled, so it fell through to
the classic orchestrator even when the canvas already held a 390px
mobile screen. Feed a canvas signal (an existing mobile-width top-level
frame) into the routing decision, explicit overrides still win. Also
surface the parallel-agents setting to the design loop model as a
prompt fact and tie the spawn_agents guidance to it instead of a dead
3-4 screen threshold, so the picker setting has honest semantics.
This commit is contained in:
Fini 2026-07-17 21:12:39 +08:00
parent debe8b88bc
commit e13ebe374c
2 changed files with 171 additions and 20 deletions

View file

@ -246,7 +246,7 @@ When the canvas holds **2 or more screens**, the preview engine can turn them in
## Parallel Work — `spawn_agents`
For a large multi-screen task (more than 3–4 screens):
When the user asks for 2+ new screens in one request AND their parallel-agents setting allows it — look for a `User's parallel-agents setting: N` fact earlier in this prompt; `N > 1` means the user opted into parallel work — prefer `spawn_agents` to build the screens concurrently. When no such fact is present (the setting is 1, the default) or it doesn't cover every new screen, design them sequentially instead, one at a time, following the "Designing Additional Screens (Screen 2+)" protocol above.
1. Create the container frames yourself using `batch_design` (one frame per screen, positioned with `find_empty_space`).
- **Pre-copy the shared chrome.** If the screens share chrome (navbar / sidebar / bottom tab bar), build it ONCE (or take it from an existing screen) and `C()`-copy it into EVERY container before spawning. Sub-agents restyle only its active state; they never rebuild chrome. N agents each inventing a navbar is how a product stops looking like one product.

View file

@ -59,33 +59,87 @@ fn prompt_is_landing(prompt: &str) -> bool {
.any(|keyword| lower.contains(keyword))
}
/// E-lite: the loop-path system-prompt fact naming the user's
/// `chat.agent_team_size` (⚡Nx) setting, so the model actually knows how
/// many parallel agents it's allowed before deciding whether to
/// `spawn_agents` for a multi-screen request — the design-agent.md
/// spawn_agents guidance no longer uses a fixed screen-count threshold, it
/// asks whether "the user's parallel-agents setting allows it", which is
/// meaningless unless the model is TOLD the setting. `None` at the default
/// of 1 — a team of one has nothing to parallelize, so the fact would only
/// add noise for the common case.
fn team_size_fact(agent_team_size: u32) -> Option<String> {
(agent_team_size > 1).then(|| format!("User's parallel-agents setting: {agent_team_size}"))
}
/// Screen-shaped top-level frame width band — mirrors
/// `op_orchestrator::wire_screen_navigation::MOBILE_SCREEN_WIDTH`. Kept as a
/// small local duplicate rather than an import: this is a host-side ROUTING
/// heuristic at a different architectural layer than the orchestrator's
/// generation-time screen-wiring pass, and reaching into that pass's
/// internals for one width check would be the wrong dependency direction.
const MOBILE_SCREEN_WIDTH: std::ops::RangeInclusive<f64> = 320.0..=480.0;
/// True when the active page already holds a screen-shaped mobile-width
/// top-level frame — i.e. the canvas is already an in-progress mobile app,
/// independent of what the CURRENT turn's prompt text says.
///
/// A pure-Chinese continuation prompt ("继续生成剩下3个页面") carries no
/// ASCII mobile keyword `root_seed_prompt_is_mobile` can match, so without
/// this canvas signal a mobile multi-screen continuation silently misroutes
/// to the classic orchestrator (see the multi-screen fan-out break plan in
/// openpencil-docs).
fn canvas_has_mobile_screen_root(state: &op_editor_core::EditorState) -> bool {
use op_editor_core::PenNodeExt;
state.active_children().iter().any(|node| {
matches!(node, jian_ops_schema::node::PenNode::Frame(_))
&& node
.width_px()
.is_some_and(|w| MOBILE_SCREEN_WIDTH.contains(&w))
})
}
/// Pure predicate for the design-agent-loop gate.
///
/// Routing has three states: a truthy env value or the Settings experimental
/// toggle forces the loop for every prompt; when neither force-loop condition
/// applies, a falsy env value forces the single-shot orchestrator; otherwise
/// mobile and landing prompts auto-route to the loop while desktop dashboard,
/// web-app, and other prompts stay on the orchestrator.
/// mobile and landing prompts, OR a canvas that already has a mobile-width
/// screen root (`existing_mobile_root`), auto-route to the loop, while
/// desktop dashboard, web-app, and other prompts on an otherwise-empty/
/// desktop canvas stay on the orchestrator.
///
/// `existing_mobile_root` is the fix for a misroute the prompt text alone
/// can't see: a continuation turn ("继续生成剩下3个页面") has no ASCII
/// mobile keyword, but the canvas it's continuing already proves the
/// product is mobile — that fact must win regardless of what THIS prompt
/// says.
///
/// The A/B evidence summarized in the openpencil-docs sonar plan validated the
/// loop as better for mobile/landing work and worse for desktop dashboard work.
///
/// Kept free of I/O so it is unit-testable without env-var flakiness.
pub fn loop_enabled(experimental: bool, env: Option<&str>, prompt: &str) -> bool {
pub fn loop_enabled(
experimental: bool,
env: Option<&str>,
prompt: &str,
existing_mobile_root: bool,
) -> bool {
if experimental {
return true;
}
if let Some(force_loop) = parse_loop_env(env) {
return force_loop;
}
root_seed_prompt_is_mobile(prompt) || prompt_is_landing(prompt)
existing_mobile_root || root_seed_prompt_is_mobile(prompt) || prompt_is_landing(prompt)
}
/// Returns true when the design-agent loop should run for this turn.
///
/// Explicit settings force one path; otherwise mobile and landing prompts use
/// the loop. CLI providers never reach this gate — they stay on the orchestrator
/// path in `launch_if_pending`.
/// Explicit settings force one path; otherwise mobile and landing prompts, or
/// an existing mobile-width screen root already on the canvas, use the loop.
/// CLI providers never reach this gate — they stay on the orchestrator path
/// in `launch_if_pending`.
pub(super) fn design_agent_loop_enabled(state: &op_editor_core::EditorState, prompt: &str) -> bool {
loop_enabled(
state.editor_ui.agent_settings.experimental_features_enabled,
@ -93,6 +147,7 @@ pub(super) fn design_agent_loop_enabled(state: &op_editor_core::EditorState, pro
.ok()
.as_deref(),
prompt,
canvas_has_mobile_screen_root(state),
)
}
@ -198,6 +253,7 @@ pub(super) fn launch_design_loop_turn(
// `&mut` borrow below.
let thinking = design_turn_thinking_mode(host);
let root_seed_mobile = root_seed_prompt_is_mobile(&user_text);
let agent_team_size = host.editor_state().chat.agent_team_size;
let chat = &mut host.editor_state_mut().chat;
let effort = chat.effort_level;
let attachments = std::mem::take(&mut chat.pending_attachments);
@ -215,6 +271,14 @@ pub(super) fn launch_design_loop_turn(
system_prompt.push_str(&brief);
op_ai_skills::append_image_self_check_scope(&mut system_prompt);
}
// E-lite: tell the model the ⚡Nx setting it's actually operating under
// (see `team_size_fact`) — independent of whether a canvas brief exists,
// so a fresh-canvas multi-screen ask with team_size > 1 still knows it
// may `spawn_agents`.
if let Some(fact) = team_size_fact(agent_team_size) {
system_prompt.push_str("\n\n---\n\n");
system_prompt.push_str(&fact);
}
let req = ChatRequest {
system_prompt,
user_message: user_text,
@ -286,18 +350,21 @@ mod tests {
#[test]
fn loop_enabled_explicit_on_routes_any_prompt_to_loop() {
let dashboard = "Design an admin analytics dashboard web app";
assert!(loop_enabled(true, None, dashboard));
assert!(loop_enabled(false, Some("1"), dashboard));
assert!(loop_enabled(false, Some("true"), dashboard));
assert!(loop_enabled(false, Some(" on "), dashboard));
assert!(loop_enabled(true, None, dashboard, false));
assert!(loop_enabled(false, Some("1"), dashboard, false));
assert!(loop_enabled(false, Some("true"), dashboard, false));
assert!(loop_enabled(false, Some(" on "), dashboard, false));
}
#[test]
fn loop_enabled_explicit_off_routes_any_prompt_to_orchestrator() {
let mobile = "Design a mobile fitness app home";
assert!(!loop_enabled(false, Some("0"), mobile));
assert!(!loop_enabled(false, Some("false"), mobile));
assert!(!loop_enabled(false, Some(" off "), mobile));
assert!(!loop_enabled(false, Some("0"), mobile, false));
assert!(!loop_enabled(false, Some("false"), mobile, false));
assert!(!loop_enabled(false, Some(" off "), mobile, false));
// An explicit force-off wins even when the canvas already has a
// mobile screen root — explicit settings always take precedence.
assert!(!loop_enabled(false, Some("0"), mobile, true));
}
#[test]
@ -305,7 +372,8 @@ mod tests {
assert!(loop_enabled(
false,
None,
"Design a mobile fitness app home"
"Design a mobile fitness app home",
false
));
}
@ -314,7 +382,8 @@ mod tests {
assert!(loop_enabled(
false,
None,
"Design a landing page for a climate SaaS"
"Design a landing page for a climate SaaS",
false
));
}
@ -323,16 +392,37 @@ mod tests {
assert!(!loop_enabled(
false,
None,
"Design an admin analytics dashboard web app"
"Design an admin analytics dashboard web app",
false
));
}
#[test]
fn loop_enabled_canvas_mobile_root_routes_ascii_blind_continuation_to_loop() {
// "继续生成剩下3个页面" (continue generating the remaining 3 pages)
// carries no ASCII mobile keyword `root_seed_prompt_is_mobile` can
// match — the multi-screen fan-out break this fixes. When the canvas
// already proves the product is mobile, that fact must route the
// continuation to the loop regardless of the prompt's own text.
assert!(loop_enabled(false, None, "继续生成剩下3个页面", true));
}
#[test]
fn loop_enabled_canvas_mobile_root_false_keeps_prior_routing_for_ascii_blind_prompt() {
// Regression lock: on an empty/desktop canvas the SAME
// ASCII-keyword-blind prompt must keep today's behavior (routed to
// the orchestrator) — the canvas signal only ADDS a route, it never
// changes the text-only outcome when it's false.
assert!(!loop_enabled(false, None, "继续生成剩下3个页面", false));
}
#[test]
fn loop_enabled_auto_routes_ambiguous_homepage_to_orchestrator() {
assert!(!loop_enabled(
false,
None,
"Design the dashboard homepage for admin console"
"Design the dashboard homepage for admin console",
false
));
}
@ -341,7 +431,68 @@ mod tests {
assert!(!loop_enabled(
false,
None,
"Design the homepage for a project management web app"
"Design the homepage for a project management web app",
false
));
}
// ── canvas-state mobile-root signal ─────────────────────────────
fn mobile_screen_frame(id: &str, width: f64) -> jian_ops_schema::node::PenNode {
serde_json::from_value(serde_json::json!({
"type": "frame",
"id": id,
"name": "Home",
"width": width,
"height": 844,
"children": []
}))
.expect("frame fixture")
}
#[test]
fn canvas_has_mobile_screen_root_true_for_390_wide_top_level_frame() {
let mut state = op_editor_core::EditorState::new();
state.active_children_mut().clear();
state
.active_children_mut()
.push(mobile_screen_frame("home", 390.0));
assert!(canvas_has_mobile_screen_root(&state));
}
#[test]
fn canvas_has_mobile_screen_root_false_for_empty_canvas() {
let mut state = op_editor_core::EditorState::new();
state.active_children_mut().clear();
assert!(!canvas_has_mobile_screen_root(&state));
}
#[test]
fn canvas_has_mobile_screen_root_false_for_desktop_width_frame() {
let mut state = op_editor_core::EditorState::new();
state.active_children_mut().clear();
state
.active_children_mut()
.push(mobile_screen_frame("home", 1440.0));
assert!(!canvas_has_mobile_screen_root(&state));
}
// ── E-lite: team-size system-prompt fact ────────────────────────
#[test]
fn team_size_fact_is_none_at_the_default_of_one() {
// A team of one has nothing to parallelize — injecting the fact
// would only add noise to every single-agent turn.
assert_eq!(team_size_fact(1), None);
}
#[test]
fn team_size_fact_names_the_setting_when_above_one() {
assert_eq!(
team_size_fact(4).as_deref(),
Some("User's parallel-agents setting: 4")
);
assert_eq!(
team_size_fact(6).as_deref(),
Some("User's parallel-agents setting: 6")
);
}
}