diff --git a/crates/op-ai-skills/skills/phases/agent/design-agent.md b/crates/op-ai-skills/skills/phases/agent/design-agent.md index b387c2728..ca1ba7da9 100644 --- a/crates/op-ai-skills/skills/phases/agent/design-agent.md +++ b/crates/op-ai-skills/skills/phases/agent/design-agent.md @@ -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. diff --git a/crates/op-host-desktop/src/chat_session_launch_design.rs b/crates/op-host-desktop/src/chat_session_launch_design.rs index d1ba507c9..bfbf20d82 100644 --- a/crates/op-host-desktop/src/chat_session_launch_design.rs +++ b/crates/op-host-desktop/src/chat_session_launch_design.rs @@ -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 { + (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 = 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") + ); + } }