diff --git a/crates/op-editor-core/src/collab_gate.rs b/crates/op-editor-core/src/collab_gate.rs index 0d05d4feb..9ae4f318c 100644 --- a/crates/op-editor-core/src/collab_gate.rs +++ b/crates/op-editor-core/src/collab_gate.rs @@ -423,6 +423,7 @@ impl EditorCommand { C::BatchInsert { .. } | C::InsertSubtree { .. } | C::InsertAuthoredSubtree { .. } + | C::InsertAuthoredSubtreePreservingRoots { .. } | C::RefineDesign { .. } | C::Batch { .. } | C::ReplaceAllMatchingProperties { .. } diff --git a/crates/op-editor-core/src/command.rs b/crates/op-editor-core/src/command.rs index cf98127b6..b18a04753 100644 --- a/crates/op-editor-core/src/command.rs +++ b/crates/op-editor-core/src/command.rs @@ -258,6 +258,18 @@ pub enum EditorCommand { /// or legacy page index. page_id: Option, }, + /// Insert already-id-authored subtrees without consuming an empty root + /// frame. Multi-operation design programs use this after handling the + /// pre-existing starter explicitly, so newly authored empty screen shells + /// remain siblings instead of replacing one another. + InsertAuthoredSubtreePreservingRoots { + nodes: Vec, + /// `NodeId::NONE` → active page root. + parent_id: NodeId, + /// `None` inserts on the active page; `Some` targets a page id + /// or legacy page index. + page_id: Option, + }, /// Run deterministic post-generation cleanup for a layered design /// root. Unlike most write commands, a valid root with no needed /// edits is still accepted so `design_refine` can be idempotent. diff --git a/crates/op-editor-core/src/command_apply.rs b/crates/op-editor-core/src/command_apply.rs index 686a72479..440d22060 100644 --- a/crates/op-editor-core/src/command_apply.rs +++ b/crates/op-editor-core/src/command_apply.rs @@ -48,8 +48,9 @@ mod helpers; pub(crate) use helpers::command_marks_document_dirty; use helpers::{ - apply_import_svg_on_active_page, apply_insert_node_on_active_page, apply_kit_component_on_page, - command_page_index, parse_align_action, parse_tool, parse_variable_kind, + apply_authored_subtree_on_page, apply_import_svg_on_active_page, + apply_insert_node_on_active_page, apply_kit_component_on_page, command_page_index, + parse_align_action, parse_tool, parse_variable_kind, }; impl EditorState { @@ -302,26 +303,12 @@ impl EditorState { nodes, parent_id, page_id, - } => { - let Some(target_page_index) = command_page_index(self, page_id.as_deref()) else { - return Ok(false); - }; - let original_page_index = self.ui.active_page_index; - if page_id.is_some() { - self.ui.active_page_index = target_page_index; - } - let snap = self.snapshot_for_history(); - let changed = if self.cmd_insert_authored_subtree(nodes, &parent_id) { - self.history_push_past(snap); - true - } else { - false - }; - if page_id.is_some() && target_page_index != original_page_index { - self.ui.active_page_index = original_page_index; - } - changed - } + } => apply_authored_subtree_on_page(self, nodes, &parent_id, page_id.as_deref(), false), + EditorCommand::InsertAuthoredSubtreePreservingRoots { + nodes, + parent_id, + page_id, + } => apply_authored_subtree_on_page(self, nodes, &parent_id, page_id.as_deref(), true), EditorCommand::RefineDesign { root_id, canvas_width, diff --git a/crates/op-editor-core/src/command_apply/helpers.rs b/crates/op-editor-core/src/command_apply/helpers.rs index e8334567a..520d35e85 100644 --- a/crates/op-editor-core/src/command_apply/helpers.rs +++ b/crates/op-editor-core/src/command_apply/helpers.rs @@ -3,6 +3,7 @@ //! active-page insert shims. use super::*; +use jian_ops_schema::node::PenNode; /// Resolve an `align` action string into an [`AlignAction`]. pub(super) fn parse_align_action(s: &str) -> Option { @@ -78,6 +79,35 @@ pub(super) fn command_page_index(state: &EditorState, page_id: Option<&str>) -> } } +pub(super) fn apply_authored_subtree_on_page( + state: &mut EditorState, + nodes: Vec, + parent_id: &NodeId, + page_id: Option<&str>, + preserve_roots: bool, +) -> bool { + let Some(target_page_index) = command_page_index(state, page_id) else { + return false; + }; + let original_page_index = state.ui.active_page_index; + if page_id.is_some() { + state.ui.active_page_index = target_page_index; + } + let snap = state.snapshot_for_history(); + let changed = if preserve_roots { + state.cmd_insert_authored_subtree_preserving_roots(nodes, parent_id) + } else { + state.cmd_insert_authored_subtree(nodes, parent_id) + }; + if changed { + state.history_push_past(snap); + } + if page_id.is_some() && target_page_index != original_page_index { + state.ui.active_page_index = original_page_index; + } + changed +} + pub(crate) fn command_marks_document_dirty(cmd: &EditorCommand) -> bool { use EditorCommand as C; if let C::Batch { commands } = cmd { diff --git a/crates/op-editor-core/src/command_authored_subtree.rs b/crates/op-editor-core/src/command_authored_subtree.rs index faea501b6..6f55046f8 100644 --- a/crates/op-editor-core/src/command_authored_subtree.rs +++ b/crates/op-editor-core/src/command_authored_subtree.rs @@ -13,16 +13,37 @@ impl EditorState { &mut self, nodes: Vec, parent_id: &NodeId, + ) -> bool { + self.cmd_insert_authored_subtree_with_root_policy(nodes, parent_id, true) + } + + pub(crate) fn cmd_insert_authored_subtree_preserving_roots( + &mut self, + nodes: Vec, + parent_id: &NodeId, + ) -> bool { + self.cmd_insert_authored_subtree_with_root_policy(nodes, parent_id, false) + } + + fn cmd_insert_authored_subtree_with_root_policy( + &mut self, + nodes: Vec, + parent_id: &NodeId, + replace_empty_root: bool, ) -> bool { if nodes.is_empty() { return false; } let mut nodes = nodes; - let replacement = crate::command_root_replace::prepare_root_frame_replacement( - self.active_children(), - &mut nodes, - parent_id, - ); + let replacement = replace_empty_root + .then(|| { + crate::command_root_replace::prepare_root_frame_replacement( + self.active_children(), + &mut nodes, + parent_id, + ) + }) + .flatten(); if parent_id.is_real() { // Accept any container (matches `cmd_insert_subtree`), including an // empty one whose `children` is still `None` — the insert below diff --git a/crates/op-editor-core/src/command_batch.rs b/crates/op-editor-core/src/command_batch.rs index 18d35aa0b..e533cf97a 100644 --- a/crates/op-editor-core/src/command_batch.rs +++ b/crates/op-editor-core/src/command_batch.rs @@ -78,6 +78,7 @@ fn batchable(cmd: &EditorCommand) -> bool { | C::BatchInsert { .. } | C::InsertSubtree { .. } | C::InsertAuthoredSubtree { .. } + | C::InsertAuthoredSubtreePreservingRoots { .. } | C::RefineDesign { .. } | C::SetVariableScalar { .. } | C::CreateVariable { .. } diff --git a/crates/op-host-desktop/src/chat_design_request.rs b/crates/op-host-desktop/src/chat_design_request.rs index d3ee18932..d623041b7 100644 --- a/crates/op-host-desktop/src/chat_design_request.rs +++ b/crates/op-host-desktop/src/chat_design_request.rs @@ -1,5 +1,5 @@ -use op_editor_core::EditorState; -use op_orchestrator::{AppendContext, DesignRequest}; +use op_editor_core::{EditorState, PenNodeExt}; +use op_orchestrator::{AppendContext, ContinuationContext, DesignRequest}; /// Resolve the selected chat model's capability id for the orchestrator. /// Built-in (API-key) agents expose their concrete model id. ACP entries @@ -35,6 +35,8 @@ pub(crate) fn build_design_request( state: &EditorState, append_context: Option, ) -> DesignRequest { + let continuation_context = + sibling_continuation_context(state, &prompt, append_context.as_ref()); DesignRequest { prompt, model: selected_orchestrator_model(state), @@ -45,6 +47,7 @@ pub(crate) fn build_design_request( // this from the agent tool executor (agent-tool-executor.ts:234); // the shell's design pipeline is that path's equivalent. append_context, + continuation_context, concurrency: state.chat.agent_team_size, validation_enabled: true, visual_ref_enabled: false, @@ -54,6 +57,35 @@ pub(crate) fn build_design_request( } } +/// Capture the existing screen's artboard contract for named sibling-screen +/// continuations. A blank starter is deliberately ignored: new documents keep +/// design-type inference instead of inheriting an arbitrary starter size. +fn sibling_continuation_context( + state: &EditorState, + prompt: &str, + append_context: Option<&AppendContext>, +) -> Option { + if append_context.is_some() { + return None; + } + let screen_names = op_host_services::chat_intent::listed_whole_screen_names(prompt); + if screen_names.is_empty() { + return None; + } + let screen = state.active_children().iter().rev().find(|node| { + matches!(node, jian_ops_schema::node::PenNode::Frame(_)) + && node.children().is_some_and(|children| !children.is_empty()) + && node.width_px().is_some() + && node.height_px().is_some() + })?; + Some(ContinuationContext { + screen_width: screen.width_px()?, + screen_height: screen.height_px()?, + background_color: op_editor_core::first_solid_fill_hex(screen).map(str::to_string), + screen_names, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -111,6 +143,46 @@ mod tests { let ctx = req.append_context.expect("append context attached"); assert_eq!(ctx.target_parent_id, "content-root"); assert_eq!(ctx.existing_section_labels, vec!["Hero".to_string()]); + assert!(req.continuation_context.is_none()); + } + + #[test] + fn named_mobile_continuation_inherits_screen_contract() { + let mut state = EditorState::new(); + state.active_children_mut().clear(); + state.active_children_mut().push( + serde_json::from_value(serde_json::json!({ + "type": "frame", + "id": "home", + "name": "Nocturne 今夜", + "width": 390, + "height": 844, + "fill": [{ "type": "solid", "color": "#050508" }], + "children": [{ "type": "text", "id": "title", "content": "今夜天空" }] + })) + .expect("existing screen"), + ); + + let req = build_design_request("继续生成 星图、观测计划、我的3个界面".into(), &state, None); + + let context = req.continuation_context.expect("continuation context"); + assert_eq!( + (context.screen_width, context.screen_height), + (390.0, 844.0) + ); + assert_eq!(context.background_color.as_deref(), Some("#050508")); + assert_eq!(context.screen_names, ["星图", "观测计划", "我的"]); + } + + #[test] + fn blank_document_does_not_invent_a_continuation_contract() { + let state = EditorState::new(); + let req = build_design_request( + "Continue generating the Explore/Profile screens".into(), + &state, + None, + ); + assert!(req.continuation_context.is_none()); } #[test] diff --git a/crates/op-host-desktop/src/chat_intent_host_tests.rs b/crates/op-host-desktop/src/chat_intent_host_tests.rs index 9e2fc23c7..0ef5d5fc2 100644 --- a/crates/op-host-desktop/src/chat_intent_host_tests.rs +++ b/crates/op-host-desktop/src/chat_intent_host_tests.rs @@ -127,6 +127,7 @@ fn test_design_request() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, diff --git a/crates/op-host-desktop/src/chat_session_launch_tests.rs b/crates/op-host-desktop/src/chat_session_launch_tests.rs index 073bfd9f3..4bbb7ffd4 100644 --- a/crates/op-host-desktop/src/chat_session_launch_tests.rs +++ b/crates/op-host-desktop/src/chat_session_launch_tests.rs @@ -79,6 +79,7 @@ fn stash_design_request_for_retry_writes_json_onto_the_last_message() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-host-desktop/src/design_session_terminal_tests.rs b/crates/op-host-desktop/src/design_session_terminal_tests.rs index daa7e7303..fa7e8da89 100644 --- a/crates/op-host-desktop/src/design_session_terminal_tests.rs +++ b/crates/op-host-desktop/src/design_session_terminal_tests.rs @@ -31,6 +31,7 @@ fn design_request_json() -> String { provider: Some("antigravity".into()), design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-host-desktop/src/design_session_tests.rs b/crates/op-host-desktop/src/design_session_tests.rs index 3057a061c..068f52ca1 100644 --- a/crates/op-host-desktop/src/design_session_tests.rs +++ b/crates/op-host-desktop/src/design_session_tests.rs @@ -292,6 +292,7 @@ fn persisted_request_json() -> String { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, diff --git a/crates/op-host-desktop/src/design_session_worker_tests.rs b/crates/op-host-desktop/src/design_session_worker_tests.rs index e17d1a524..d7893ee03 100644 --- a/crates/op-host-desktop/src/design_session_worker_tests.rs +++ b/crates/op-host-desktop/src/design_session_worker_tests.rs @@ -39,6 +39,7 @@ fn persisted_request_json() -> String { provider: None, design_md: None, concurrency: 2, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, diff --git a/crates/op-host-desktop/src/sub_agent_session.rs b/crates/op-host-desktop/src/sub_agent_session.rs index 5d2828222..0a7e33c6d 100644 --- a/crates/op-host-desktop/src/sub_agent_session.rs +++ b/crates/op-host-desktop/src/sub_agent_session.rs @@ -43,8 +43,7 @@ use op_host_native::WidgetHostNative; use op_mcp::spawn_agents_tool::SpawnSpec; use op_orchestrator::agent_identity::assign_agent_identities_seeded; -use op_ai_skills::style_guide::{select_style_guide, style_guide_registry, SelectOptions}; -use op_editor_core::{agent_indicators, ChatMessage}; +use op_editor_core::{agent_indicators, ChatMessage, PenNodeExt}; use op_host_services::design_agent_tools::root_seed_prompt_is_mobile; use crate::chat_session::{self, builtin_provider_with_design_tools, ChatSession}; @@ -160,14 +159,9 @@ pub(crate) fn build_sub_agent_prompt(spec: &SpawnSpec, context_brief: Option<&st } // Styleguide — resolve the parent-passed name to its markdown content. - if let Some(guide) = select_style_guide( - style_guide_registry(), - &SelectOptions { - name: Some(spec.styleguide_name.clone()), - tags: vec![], - platform: None, - }, - ) { + // Both halves of the catalogue: the name may be a `user:` id for a guide + // the user imported from a DESIGN.md. + if let Some(guide) = op_ai_skills::style_guide::find_style_guide(&spec.styleguide_name) { prompt.push_str("\n\n## Style guide\n\n"); prompt.push_str(&guide.content); } @@ -217,6 +211,7 @@ pub(crate) fn launch_sub_agents( // sub sees the same screen inventory, palette, typefaces, and copyable // chrome node ids, so N agents converge on ONE product instead of N. let context_brief = op_host_services::design_context::design_context_brief(host.editor_state()); + let existing_mobile_screen = canvas_has_mobile_screen(host.editor_state()); for (spec, identity) in specs.into_iter().zip(identities) { // A fresh provider + tool channel per sub; skip if no ready @@ -247,7 +242,7 @@ pub(crate) fn launch_sub_agents( session: Some(session), identity, indicator: None, - root_seed_mobile: root_seed_prompt_is_mobile(&spec.prompt), + root_seed_mobile: existing_mobile_screen || root_seed_prompt_is_mobile(&spec.prompt), }); } @@ -256,6 +251,16 @@ pub(crate) fn launch_sub_agents( subs } +fn canvas_has_mobile_screen(state: &op_editor_core::EditorState) -> bool { + state.active_children().iter().any(|node| { + matches!(node, jian_ops_schema::node::PenNode::Frame(_)) + && node.children().is_some_and(|children| !children.is_empty()) + && node + .width_px() + .is_some_and(|width| (320.0..=480.0).contains(&width)) + }) +} + /// Abort every sub-agent loop (MT.3 close-of-running-tab): end the active /// sub's live indicator epoch so the canvas badge glow doesn't get stuck, /// drop all sessions, and reset the cursor. The caller is expected to clear diff --git a/crates/op-host-desktop/src/sub_agent_session_tests.rs b/crates/op-host-desktop/src/sub_agent_session_tests.rs index 420f9bd3a..4c95f4745 100644 --- a/crates/op-host-desktop/src/sub_agent_session_tests.rs +++ b/crates/op-host-desktop/src/sub_agent_session_tests.rs @@ -6,7 +6,7 @@ //! against the process-global `agent_indicators` registry. use super::*; -use op_editor_core::{agent_indicators, EditorState}; +use op_editor_core::{agent_indicators, EditorState, PenNodeExt}; use op_host_services::design_agent_tools::execute_design_tool; /// A real styleguide name from the embedded corpus. @@ -45,6 +45,30 @@ fn insert_frame(state: &mut EditorState) -> String { .expect("a new frame id appeared") } +#[test] +fn spawned_screen_agents_inherit_mobile_class_from_existing_canvas() { + let mut state = EditorState::new(); + state.active_children_mut().clear(); + state.active_children_mut().push( + serde_json::from_value(serde_json::json!({ + "type": "frame", "id": "home", "name": "Home", + "width": 390, "height": 844, + "children": [{ "type": "text", "id": "title", "content": "Home" }] + })) + .expect("mobile screen"), + ); + assert!(canvas_has_mobile_screen(&state)); + + state.active_children_mut()[0] + .children_mut() + .expect("children") + .clear(); + assert!( + !canvas_has_mobile_screen(&state), + "a blank starter must not turn a fresh design into a continuation" + ); +} + // --------------------------------------------------------------------------- // build_sub_agent_prompt // --------------------------------------------------------------------------- diff --git a/crates/op-host-services/src/chat_intent.rs b/crates/op-host-services/src/chat_intent.rs index 08c1c2fca..319e4cb8c 100644 --- a/crates/op-host-services/src/chat_intent.rs +++ b/crates/op-host-services/src/chat_intent.rs @@ -57,6 +57,7 @@ use crate::design_session::{run_design_worker, DesignCmdReq, DesignDelta}; #[path = "chat_intent_screen_sets.rs"] mod screen_sets; +pub use screen_sets::listed_whole_screen_names; use screen_sets::requests_listed_whole_screens; /// Internal host-op name the modify worker sends over the chat tool diff --git a/crates/op-host-services/src/chat_intent_modify_turn_tests.rs b/crates/op-host-services/src/chat_intent_modify_turn_tests.rs index 25ea90f3b..88fd323c3 100644 --- a/crates/op-host-services/src/chat_intent_modify_turn_tests.rs +++ b/crates/op-host-services/src/chat_intent_modify_turn_tests.rs @@ -12,6 +12,7 @@ fn test_design_request() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, diff --git a/crates/op-host-services/src/chat_intent_screen_sets.rs b/crates/op-host-services/src/chat_intent_screen_sets.rs index 2a98a1c6a..de4552d03 100644 --- a/crates/op-host-services/src/chat_intent_screen_sets.rs +++ b/crates/op-host-services/src/chat_intent_screen_sets.rs @@ -44,15 +44,58 @@ fn has_name_chars(part: &str) -> bool { letters.iter().any(|c| !c.is_ascii()) || letters.len() >= 3 } -fn is_named_list_with(targets: &str, separator: char) -> bool { - let parts: Vec<&str> = targets.split(separator).map(str::trim).collect(); - parts.len() >= 2 && parts.iter().all(|part| has_name_chars(part)) +fn normalize_target_name(raw: &str) -> Option { + let mut name = raw + .trim() + .trim_matches(|c: char| matches!(c, ',' | ',' | ':' | ':')) + .to_string(); + + // A shared CJK screen noun commonly carries the declared count after the + // final name: `星图、观测计划、我的3个界面`. The count describes the list; it is + // not part of the last screen's name. + if name.ends_with('个') { + name.pop(); + name = name.trim_end().to_string(); + while name + .chars() + .next_back() + .is_some_and(|c| c.is_ascii_digit() || "零一二三四五六七八九十两几".contains(c)) + { + name.pop(); + } + name = name + .trim_end() + .trim_end_matches('这') + .trim_end() + .to_string(); + } + + if let Some(without_article) = name + .strip_prefix("the ") + .or_else(|| name.strip_prefix("The ")) + { + name = without_article.trim().to_string(); + } + + has_name_chars(&name).then_some(name) } -fn has_named_list(targets: &str) -> bool { - is_named_list_with(targets, '/') - || is_named_list_with(targets, '、') - || (!targets.contains("://") && !targets.contains("//") && is_named_list_with(targets, '/')) +fn named_list_with(targets: &str, separator: char) -> Option> { + let parts = targets + .split(separator) + .map(normalize_target_name) + .collect::>>()?; + (parts.len() >= 2).then_some(parts) +} + +fn named_list_names(targets: &str) -> Option> { + named_list_with(targets, '/') + .or_else(|| named_list_with(targets, '、')) + .or_else(|| { + (!targets.contains("://") && !targets.contains("//")) + .then(|| named_list_with(targets, '/')) + .flatten() + }) } fn is_clause_end(tail: &str) -> bool { @@ -132,7 +175,7 @@ fn targets_reference_existing_screen_en(targets: &str) -> bool { .any(|marker| contains_ascii_word(targets, marker)) } -fn requests_listed_whole_screens_en(prompt: &str) -> bool { +fn listed_whole_screen_names_en(prompt: &str) -> Option> { let lower = prompt.to_ascii_lowercase(); for &verb in LIST_VERBS_EN { for (verb_pos, _) in lower.match_indices(verb) { @@ -142,28 +185,36 @@ fn requests_listed_whole_screens_en(prompt: &str) -> bool { continue; } let after_verb = &lower[verb_pos + verb.len()..]; + let after_verb_original = &prompt[verb_pos + verb.len()..]; for &noun in SCREEN_NOUNS_EN { for (noun_pos, _) in after_verb.match_indices(noun) { if !is_ascii_word_match(after_verb, noun_pos, noun) { continue; } - let targets = after_verb[..noun_pos].trim(); - let tail = &after_verb[noun_pos + noun.len()..]; + let targets = after_verb_original[..noun_pos].trim(); + let tail = &after_verb_original[noun_pos + noun.len()..]; if !crosses_clause_boundary(targets) && !targets_reference_existing_screen_en(targets) - && has_named_list(targets) && is_clause_end(tail) { - return true; + if let Some(names) = named_list_names(targets) { + return Some(names); + } } } } } } - false + None } -pub(super) fn requests_listed_whole_screens(prompt: &str) -> bool { +/// Exact sibling-screen names promised by a listed continuation request. +/// +/// This is the structured counterpart to the routing predicate below: the +/// desktop host attaches these names to `DesignRequest`, allowing a planning +/// fallback to create one real top-level screen per promise instead of a +/// generic `Section 1` board. +pub fn listed_whole_screen_names(prompt: &str) -> Vec { for &verb in LIST_VERBS_CJK { for (verb_pos, _) in prompt.match_indices(verb) { let after_verb = &prompt[verb_pos + verb.len()..]; @@ -178,21 +229,38 @@ pub(super) fn requests_listed_whole_screens(prompt: &str) -> bool { let tail = &after_verb[noun_pos + noun.len()..]; if !crosses_clause_boundary(targets) && !targets_reference_existing_screen(targets) - && has_named_list(targets) && is_clause_end(tail) { - return true; + if let Some(names) = named_list_names(targets) { + return names; + } } } } } } - requests_listed_whole_screens_en(prompt) + listed_whole_screen_names_en(prompt).unwrap_or_default() +} + +pub(super) fn requests_listed_whole_screens(prompt: &str) -> bool { + !listed_whole_screen_names(prompt).is_empty() } #[cfg(test)] mod tests { - use super::requests_listed_whole_screens; + use super::{listed_whole_screen_names, requests_listed_whole_screens}; + + #[test] + fn extracts_promised_screen_names_and_drops_the_declared_count() { + assert_eq!( + listed_whole_screen_names("继续生成 星图、观测计划、我的3个界面"), + ["星图", "观测计划", "我的"] + ); + assert_eq!( + listed_whole_screen_names("Continue generating the explore/profile interface"), + ["explore", "profile"] + ); + } #[test] fn recognizes_listed_follow_on_screens_with_shared_cjk_noun() { diff --git a/crates/op-host-services/src/design_agent_tools.rs b/crates/op-host-services/src/design_agent_tools.rs index 886ba3e5f..4925e5164 100644 --- a/crates/op-host-services/src/design_agent_tools.rs +++ b/crates/op-host-services/src/design_agent_tools.rs @@ -488,3 +488,7 @@ mod tests; #[cfg(test)] #[path = "design_agent_tools_scan_tests.rs"] mod scan_tests; + +#[cfg(test)] +#[path = "design_agent_tools_continuation_tests.rs"] +mod continuation_tests; diff --git a/crates/op-host-services/src/design_agent_tools/root_seed.rs b/crates/op-host-services/src/design_agent_tools/root_seed.rs index d4557f8c8..f67d91668 100644 --- a/crates/op-host-services/src/design_agent_tools/root_seed.rs +++ b/crates/op-host-services/src/design_agent_tools/root_seed.rs @@ -11,6 +11,15 @@ pub enum RootSeedTarget { Desktop, } +#[derive(Debug, Clone, PartialEq)] +struct RootSeedProfile { + target: RootSeedTarget, + width: f64, + height: f64, + background_color: Option, + inherited: bool, +} + impl RootSeedTarget { fn from_mobile(mobile: bool) -> Self { if mobile { @@ -119,20 +128,30 @@ pub(super) fn maybe_apply_root_seed_guard( .and_then(op_editor_core::agent_indicators::root_seed_hint_if_pending) .map(RootSeedTarget::from_mobile); let target = explicit_target.or(epoch_target)?; - let seed_hint = seed_root_frame_if_needed(state, ids_before, target); + let profile = resolve_root_seed_profile(state, ids_before, target); + let allow_existing_single_root = !profile.inherited; + let seed_hint = + seed_root_frame_if_needed(state, ids_before, &profile, allow_existing_single_root); // Mobile chrome parity with the orchestrator scaffold: the loop's first // batch gets the SAME pre-inserted status bar, so `mobile-app.md`'s // "status bar is already pre-inserted" contract holds on this path too. // Runs even when the model authored explicit root dimensions (the seed // above early-returns then, but the chrome must still land). - let chrome_hint = (target == RootSeedTarget::Mobile) - .then(|| inject_mobile_status_bar_if_missing(state, ids_before)) + let chrome_hint = (profile.target == RootSeedTarget::Mobile) + .then(|| inject_mobile_status_bar_if_missing(state, ids_before, allow_existing_single_root)) .flatten(); - if let Some(guard) = root_seed_guard { - guard.mark_consumed(); - } else if let Some(epoch) = indicator_epoch { - op_editor_core::agent_indicators::mark_root_seed_guard_consumed(epoch); + // A fresh-design guard is a one-shot default for the first root. A + // continuation contract is turn-scoped instead: DeepSeek commonly emits + // one sibling screen per batch, and a first batch may only modify the old + // screen. Keep the guard pending so every later top-level root inherits + // the same artboard and chrome contract. + if !profile.inherited { + if let Some(guard) = root_seed_guard { + guard.mark_consumed(); + } else if let Some(epoch) = indicator_epoch { + op_editor_core::agent_indicators::mark_root_seed_guard_consumed(epoch); + } } match (seed_hint, chrome_hint) { @@ -151,56 +170,78 @@ pub(super) fn maybe_apply_root_seed_guard( pub(super) fn inject_mobile_status_bar_if_missing( state: &mut EditorState, ids_before: &HashSet, + allow_existing_single_root: bool, ) -> Option { - let root = root_seed_candidate_mut(state, ids_before)?; - // OS chrome has exactly one canonical form. A model-built status bar - // (name matches, structure doesn't — no role, ad-hoc children) is - // REPLACED in place rather than kept: every hand-rolled variant we - // measured deviated visibly from the iOS reference (GLM-5.2 2026-07-11). - let noncanonical_index = root - .children() - .into_iter() - .flatten() - .position(|child| is_status_bar_node(child) && !is_canonical_status_bar(child)); - if let Some(index) = noncanonical_index { + let candidate_indices = + root_seed_candidate_indices(state, ids_before, allow_existing_single_root); + let mut inserted = false; + let mut replaced = false; + for index in candidate_indices { + let Some(root) = state.active_children_mut().get_mut(index) else { + continue; + }; + // OS chrome has exactly one canonical form. A model-built status bar + // (name matches, structure doesn't — no role, ad-hoc children) is + // REPLACED in place rather than kept: every hand-rolled variant we + // measured deviated visibly from the iOS reference (GLM-5.2 2026-07-11). + let noncanonical_index = root + .children() + .into_iter() + .flatten() + .position(|child| is_status_bar_node(child) && !is_canonical_status_bar(child)); + if let Some(index) = noncanonical_index { + let root_id = root.id_str().to_string(); + let fill_hex = op_editor_core::first_solid_fill_hex(root) + .unwrap_or("#ffffff") + .to_string(); + let width = root.width_px().unwrap_or(390.0); + if let Ok(bar) = + op_orchestrator::scaffold::mobile_status_bar_node(&root_id, &fill_hex, width) + { + if let Some(children) = root.children_mut() { + children[index] = bar; + replaced = true; + } + } + continue; + } + if root + .children() + .into_iter() + .flatten() + .any(is_status_bar_node) + { + continue; + } let root_id = root.id_str().to_string(); let fill_hex = op_editor_core::first_solid_fill_hex(root) .unwrap_or("#ffffff") .to_string(); let width = root.width_px().unwrap_or(390.0); - if let Ok(bar) = - op_orchestrator::scaffold::mobile_status_bar_node(&root_id, &fill_hex, width) - { - if let Some(children) = root.children_mut() { - children[index] = bar; - return Some( - "The status bar you built was replaced with the standard iOS status bar (62px, role=status-bar) - do NOT rebuild or restyle it." - .to_string(), - ); - } + let Ok(bar) = op_orchestrator::scaffold::mobile_status_bar_node(&root_id, &fill_hex, width) + else { + continue; + }; + if let Some(children) = root.children_mut() { + children.insert(0, bar); + inserted = true; } - return None; } - if root - .children() - .into_iter() - .flatten() - .any(is_status_bar_node) - { - return None; + if replaced { + Some( + "The status bar you built was replaced with the standard iOS status bar \ + (62px, role=status-bar) - do NOT rebuild or restyle it." + .to_string(), + ) + } else if inserted { + Some( + "A standard iOS status bar (62px, role=status-bar) was pre-inserted as the root's \ + first child - do NOT create another status bar; start your content below it." + .to_string(), + ) + } else { + None } - let root_id = root.id_str().to_string(); - let fill_hex = op_editor_core::first_solid_fill_hex(root) - .unwrap_or("#ffffff") - .to_string(); - let width = root.width_px().unwrap_or(390.0); - let bar = op_orchestrator::scaffold::mobile_status_bar_node(&root_id, &fill_hex, width).ok()?; - root.children_mut()?.insert(0, bar); - Some( - "A standard iOS status bar (62px, role=status-bar) was pre-inserted as the root's \ - first child - do NOT create another status bar; start your content below it." - .to_string(), - ) } /// The injected/scaffold chrome shape: role tag + the Time/Levels pair. @@ -264,51 +305,114 @@ pub(super) fn remove_nested_duplicate_status_bars(state: &mut EditorState) -> us removed } -pub(super) fn seed_root_frame_if_needed( +fn seed_root_frame_if_needed( state: &mut EditorState, ids_before: &HashSet, - target: RootSeedTarget, + profile: &RootSeedProfile, + allow_existing_single_root: bool, ) -> Option { - let root = root_seed_candidate_mut(state, ids_before)?; - let width_before = root.width_px(); - let height_before = root.height_px(); - if width_before.is_some() && height_before.is_some() { + let candidate_indices = + root_seed_candidate_indices(state, ids_before, allow_existing_single_root); + let mut changed = 0usize; + for index in candidate_indices { + let Some(root) = state.active_children_mut().get_mut(index) else { + continue; + }; + let width_before = root.width_px(); + let height_before = root.height_px(); + if width_before.is_none() || (profile.inherited && width_before != Some(profile.width)) { + root.set_width_px(profile.width); + changed += 1; + } + if height_before.is_none() || (profile.inherited && height_before != Some(profile.height)) { + root.set_height_px(profile.height); + changed += 1; + } + if let Some(color) = profile.background_color.as_deref() { + if op_editor_core::first_solid_fill_hex(root) != Some(color) + && op_editor_core::fills::set_primary_fill_hex(root, color) + { + changed += 1; + } + } + default_root_layout_to_vertical(root); + } + if changed == 0 { return None; } - let (target_width, target_height) = target.dimensions(); - if width_before.is_none() { - root.set_width_px(target_width); - } - if height_before.is_none() { - root.set_height_px(target_height); - } - default_root_layout_to_vertical(root); - - let width = root.width_px().unwrap_or(target_width); - let height = root.height_px().unwrap_or(target_height); Some(format!( "root seeded to {}x{} - grow height if content exceeds.", - format_seed_dimension(width), - format_seed_dimension(height) + format_seed_dimension(profile.width), + format_seed_dimension(profile.height) )) } -pub(super) fn root_seed_candidate_mut<'a>( - state: &'a mut EditorState, +fn resolve_root_seed_profile( + state: &EditorState, ids_before: &HashSet, -) -> Option<&'a mut PenNode> { - let roots = state.active_children_mut(); - let new_root_index = roots - .iter() - .position(|node| !ids_before.contains(node.id_str()) && matches!(node, PenNode::Frame(_))); - if let Some(index) = new_root_index { - return roots.get_mut(index); + target: RootSeedTarget, +) -> RootSeedProfile { + let matching_existing_screen = state.active_children().iter().rev().find(|node| { + if !ids_before.contains(node.id_str()) + || !matches!(node, PenNode::Frame(_)) + || !node.children().is_some_and(|children| !children.is_empty()) + { + return false; + } + let Some(width) = node.width_px() else { + return false; + }; + let Some(height) = node.height_px() else { + return false; + }; + let mobile_width = (320.0..=480.0).contains(&width); + width.is_finite() + && height.is_finite() + && width > 0.0 + && height > 0.0 + && mobile_width == (target == RootSeedTarget::Mobile) + }); + if let Some(screen) = matching_existing_screen { + return RootSeedProfile { + target, + width: screen.width_px().expect("matched numeric width"), + height: screen.height_px().expect("matched numeric height"), + background_color: op_editor_core::first_solid_fill_hex(screen).map(str::to_string), + inherited: true, + }; } - if roots.len() == 1 && matches!(roots[0], PenNode::Frame(_)) { - roots.get_mut(0) + let (width, height) = target.dimensions(); + RootSeedProfile { + target, + width, + height, + background_color: None, + inherited: false, + } +} + +fn root_seed_candidate_indices( + state: &EditorState, + ids_before: &HashSet, + allow_existing_single_root: bool, +) -> Vec { + let roots = state.active_children(); + let new_root_indices = roots + .iter() + .enumerate() + .filter_map(|(index, node)| { + (!ids_before.contains(node.id_str()) && matches!(node, PenNode::Frame(_))) + .then_some(index) + }) + .collect::>(); + if !new_root_indices.is_empty() { + return new_root_indices; + } + if allow_existing_single_root && roots.len() == 1 && matches!(roots[0], PenNode::Frame(_)) { + vec![0] } else { - None + Vec::new() } } diff --git a/crates/op-host-services/src/design_agent_tools_continuation_tests.rs b/crates/op-host-services/src/design_agent_tools_continuation_tests.rs new file mode 100644 index 000000000..a839e869b --- /dev/null +++ b/crates/op-host-services/src/design_agent_tools_continuation_tests.rs @@ -0,0 +1,82 @@ +//! Sequential continuation regressions for the turn-scoped root contract. + +use super::*; + +#[test] +fn continuation_guard_survives_existing_only_batch_and_normalizes_later_roots() { + let mut state = EditorState::new(); + state.active_children_mut().clear(); + state.active_children_mut().push( + serde_json::from_value(serde_json::json!({ + "type": "frame", "id": "home", "name": "Nocturne 今夜", + "width": 390, "height": 844, + "fill": [{ "type": "solid", "color": "#050508" }], + "children": [{ "type": "text", "id": "home-title", "content": "今夜天空" }] + })) + .expect("existing mobile screen"), + ); + let mut guard = RootSeedGuard::from_prompt("mobile continuation"); + + // DeepSeek may spend its first batch editing the old screen. That must + // neither mutate its chrome nor consume the contract needed by siblings. + let (first, first_mutated) = execute_design_tool_with_root_seed_guard( + &mut state, + "batch_design", + r#"{"operations":"note=I(\"home\",{type:'text',name:'Existing screen note',content:'keep going',width:120,height:20})"}"#, + None, + Some(&mut guard), + ); + assert!(!first.is_error, "first batch failed: {}", first.content); + assert!(first_mutated); + assert_eq!(state.active_children().len(), 1); + assert!(state.active_children()[0] + .children() + .into_iter() + .flatten() + .all(|child| child.base().role.as_deref() != Some("status-bar"))); + + let (second, second_mutated) = execute_design_tool_with_root_seed_guard( + &mut state, + "batch_design", + r##"{"operations":"star=I(null,{type:'frame',name:'星图',width:1512,height:982,fill:[{type:'solid',color:'#16002E'}]})"}"##, + None, + Some(&mut guard), + ); + assert!(!second.is_error, "second batch failed: {}", second.content); + assert!(second_mutated); + + let (third, third_mutated) = execute_design_tool_with_root_seed_guard( + &mut state, + "batch_design", + r#"{"operations":"plan=I(null,{type:'frame',name:'观测计划',width:375,height:812})"}"#, + None, + Some(&mut guard), + ); + assert!(!third.is_error, "third batch failed: {}", third.content); + assert!(third_mutated); + + for name in ["星图", "观测计划"] { + let root = state + .active_children() + .iter() + .find(|node| node.base().name.as_deref() == Some(name)) + .unwrap_or_else(|| panic!("missing generated screen {name}")); + assert_eq!( + (root.width_px(), root.height_px()), + (Some(390.0), Some(844.0)), + "{name} must inherit the live artboard" + ); + assert_eq!( + op_editor_core::first_solid_fill_hex(root), + Some("#050508"), + "{name} must inherit the live background" + ); + assert_eq!( + root.children() + .and_then(|children| children.first()) + .and_then(|child| child.base().role.as_deref()), + Some("status-bar"), + "{name} must receive canonical mobile chrome" + ); + } +} diff --git a/crates/op-host-services/src/design_agent_tools_tests.rs b/crates/op-host-services/src/design_agent_tools_tests.rs index 367221486..736afe7f4 100644 --- a/crates/op-host-services/src/design_agent_tools_tests.rs +++ b/crates/op-host-services/src/design_agent_tools_tests.rs @@ -456,6 +456,62 @@ fn execute_design_root_seed_preserves_authored_numeric_width() { assert_eq!(root.height_px(), Some(844.0)); } +#[test] +fn continuation_seed_inherits_every_mobile_screen_and_repairs_wrong_numeric_sizes() { + let mut state = EditorState::new(); + state.active_children_mut().clear(); + state.active_children_mut().push( + serde_json::from_value(serde_json::json!({ + "type": "frame", "id": "home", "name": "Nocturne 今夜", + "width": 390, "height": 844, + "fill": [{ "type": "solid", "color": "#050508" }], + "children": [{ "type": "text", "id": "home-title", "content": "今夜天空" }] + })) + .expect("existing mobile screen"), + ); + let mut guard = RootSeedGuard::from_prompt("mobile continuation"); + let (result, mutated) = execute_design_tool_with_root_seed_guard( + &mut state, + "batch_design", + r#"{"operations":"a=I(null,{type:'frame',name:'星图',width:1512,height:982,fill:[{type:'solid',color:'#16002E'}]})\nb=I(null,{type:'frame',name:'观测计划'})\nc=I(null,{type:'frame',name:'我的',width:375,height:812})"}"#, + None, + Some(&mut guard), + ); + + assert!(!result.is_error, "batch failed: {}", result.content); + assert!(mutated); + let generated = &state.active_children()[1..]; + assert_eq!( + generated.len(), + 3, + "top-level roots: {:?}; result: {}", + state + .active_children() + .iter() + .map(|node| node.base().name.as_deref()) + .collect::>(), + result.content + ); + for root in generated { + assert_eq!( + (root.width_px(), root.height_px()), + (Some(390.0), Some(844.0)) + ); + assert_eq!(op_editor_core::first_solid_fill_hex(root), Some("#050508")); + assert_eq!( + root.children() + .and_then(|children| children.first()) + .and_then(|child| child.base().role.as_deref()), + Some("status-bar") + ); + } + let value: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + assert!(value["layoutHint"] + .as_str() + .unwrap_or("") + .contains("390x844")); +} + #[test] fn execute_design_mobile_first_batch_injects_status_bar_chrome() { // Chrome parity with the orchestrator scaffold: even when the model diff --git a/crates/op-host-services/src/design_session_tests.rs b/crates/op-host-services/src/design_session_tests.rs index cb1ceae6b..5ff242348 100644 --- a/crates/op-host-services/src/design_session_tests.rs +++ b/crates/op-host-services/src/design_session_tests.rs @@ -58,6 +58,7 @@ mod spawn_worker_tests { provider: None, design_md: None, concurrency: 3, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, @@ -194,6 +195,7 @@ mod subtask_retry_tests { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, diff --git a/crates/op-host-services/src/validation_providers.rs b/crates/op-host-services/src/validation_providers.rs index 05e22136b..b07deab74 100644 --- a/crates/op-host-services/src/validation_providers.rs +++ b/crates/op-host-services/src/validation_providers.rs @@ -577,6 +577,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: enabled, visual_ref_enabled: false, diff --git a/crates/op-host-services/src/web_chat_standard.rs b/crates/op-host-services/src/web_chat_standard.rs index f86c833df..4e13654d2 100644 --- a/crates/op-host-services/src/web_chat_standard.rs +++ b/crates/op-host-services/src/web_chat_standard.rs @@ -481,6 +481,7 @@ fn stream_new_design_route( model: model.clone(), provider: None, design_md: snapshot.doc.design_md.clone(), + continuation_context: None, append_context, concurrency: req .agent_team_size diff --git a/crates/op-mcp/src/batch_program.rs b/crates/op-mcp/src/batch_program.rs index b5687a267..c3c59bcfd 100644 --- a/crates/op-mcp/src/batch_program.rs +++ b/crates/op-mcp/src/batch_program.rs @@ -75,9 +75,8 @@ use super::batch_program_exec_ops::{ }; use super::batch_program_parse::{parse_json_arg, parse_node_json, regex}; use super::batch_program_resolve::{ - count_forest, find_node_by_path, first_empty_frame, line_preview, lookup_id, parent_node_id, - resolve_page_index, resolve_parent_ref, resolve_path_expr, resolve_ref, strip_outer_quotes, - with_page_id, + count_forest, find_node_by_path, line_preview, lookup_id, parent_node_id, resolve_page_index, + resolve_parent_ref, resolve_path_expr, resolve_ref, strip_outer_quotes, with_page_id, }; /// Every fallible step of the executor fails with [`ProgramError`]. @@ -111,6 +110,7 @@ pub(crate) fn run_batch_design_program( auto_seq: 0, current_line: 0, explicitly_sized_append_lines: explicitly_sized_append_lines(&lines), + replaceable_empty_root_ids: Vec::new(), }; // Pin the sim's active page to the requested page so sim READS // (path lookups, node counts) see the same children every emitted @@ -123,6 +123,24 @@ pub(crate) fn run_batch_design_program( }); } } + // Only roots that were empty BEFORE this program began are starter + // placeholders. An empty root inserted by an earlier line is a real + // sibling screen, not a new placeholder for the next I(null, ...) line. + // Without this snapshot, a three-screen shell batch repeatedly replaced + // its own previous insert and silently kept only the final screen. + ctx.replaceable_empty_root_ids = ctx + .sim + .active_children() + .iter() + .filter(|node| { + matches!(node, PenNode::Frame(_)) + && node + .children() + .map(|children| children.is_empty()) + .unwrap_or(true) + }) + .map(|node| node.id_str().to_string()) + .collect(); // Live-doc node count BEFORE any line runs — the honest `nodeCount` // for a rolled-back transaction (nothing will have been applied). let baseline_count = count_forest(ctx.sim.active_children()); @@ -198,6 +216,9 @@ pub(crate) struct ProgramCtx { /// Append G() lines whose result binding receives explicit positive /// numeric width and height later in this same program. pub(crate) explicitly_sized_append_lines: BTreeSet, + /// Root placeholders that existed before this program started. Consumed + /// at most once so newly inserted empty screen shells remain siblings. + pub(crate) replaceable_empty_root_ids: Vec, } impl ProgramCtx { @@ -430,12 +451,29 @@ fn execute_insert(binding: &str, args: &str, ctx: &mut ProgramCtx) -> Result<()> let mut node = parse_node_json(&args[comma + 1..], ctx.post_process)?; delete_superseded_draft(binding, parent.as_deref(), &node, ctx); - // TS auto-replace: a root-level frame insert replaces the first - // EMPTY root frame (inheriting its x/y) instead of siblinging it. + // TS auto-replace: a root-level frame insert replaces the first EMPTY + // starter root (inheriting its x/y) instead of siblinging it. Restrict the + // candidates to the pre-program snapshot: newly inserted empty frames are + // authored screen shells and must not replace one another. let mut pre_commands: Vec<(EditorCommand, &str)> = Vec::new(); if parent.is_none() && matches!(node, PenNode::Frame(_)) { - if let Some(empty) = first_empty_frame(ctx.sim.active_children()) { - let (id, x, y) = (empty.id_str().to_string(), empty.base().x, empty.base().y); + let replaceable = ctx.replaceable_empty_root_ids.iter().find_map(|id| { + ctx.sim + .active_children() + .iter() + .find(|candidate| { + candidate.id_str() == id + && matches!(candidate, PenNode::Frame(_)) + && candidate + .children() + .map(|children| children.is_empty()) + .unwrap_or(true) + }) + .map(|empty| (id.clone(), empty.base().x, empty.base().y)) + }); + if let Some((id, x, y)) = replaceable { + ctx.replaceable_empty_root_ids + .retain(|candidate| candidate != &id); if x.is_some() { node.base_mut().x = x; } @@ -470,7 +508,7 @@ fn execute_insert(binding: &str, args: &str, ctx: &mut ProgramCtx) -> Result<()> // recorded — state from a line that never landed must not ship). let merge = super::batch_design::hoist_generation_state(&mut nodes); ctx.emit( - EditorCommand::InsertAuthoredSubtree { + EditorCommand::InsertAuthoredSubtreePreservingRoots { nodes, parent_id: parent_node_id(parent.as_deref()), page_id: ctx.page_id.clone(), diff --git a/crates/op-mcp/src/batch_program_resolve.rs b/crates/op-mcp/src/batch_program_resolve.rs index d8f85c28b..af8499543 100644 --- a/crates/op-mcp/src/batch_program_resolve.rs +++ b/crates/op-mcp/src/batch_program_resolve.rs @@ -174,14 +174,6 @@ pub(crate) fn with_page_id(cmd: EditorCommand, page_id: Option) -> Edito } } -/// TS `isEmptyFrame` over the page roots — the first root-level frame -/// with no children. -pub(crate) fn first_empty_frame(children: &[PenNode]) -> Option<&PenNode> { - children.iter().find(|node| { - matches!(node, PenNode::Frame(_)) && node.children().map(|c| c.is_empty()).unwrap_or(true) - }) -} - /// Mirror of `command_apply::command_page_index`'s explicit-page arm: /// page id match first, then a legacy numeric index. pub(crate) fn resolve_page_index(state: &EditorState, raw: &str) -> Option { diff --git a/crates/op-mcp/src/batch_program_tests.rs b/crates/op-mcp/src/batch_program_tests.rs index 763e6f0f1..6d39f100f 100644 --- a/crates/op-mcp/src/batch_program_tests.rs +++ b/crates/op-mcp/src/batch_program_tests.rs @@ -452,6 +452,29 @@ fn root_frame_insert_replaces_the_first_empty_frame_and_inherits_position() { assert_eq!(page.base().y, Some(40.0), "inherits the empty frame's y"); } +#[test] +fn root_frame_batch_replaces_only_preexisting_empty_starters() { + let mut state = state_with(vec![frame("f1", "Blank", 30.0, 40.0, 100.0, 100.0, vec![])]); + let program = "a=I(null, {type:'frame', name:'A'})\nb=I(null, {type:'frame', name:'B'})\nc=I(null, {type:'frame', name:'C'})"; + let (envelope, cmd) = call_operations(&state, program); + assert!(envelope.get("errors").is_none(), "{envelope}"); + + assert!(state.apply(cmd.expect("three-screen batch"))); + let roots = state.active_children(); + assert_eq!(roots.len(), 3, "all authored screen shells must survive"); + assert_eq!( + roots + .iter() + .filter_map(|root| root.base().name.as_deref()) + .collect::>(), + ["A", "B", "C"] + ); + assert_eq!( + (roots[0].base().x, roots[0].base().y), + (Some(30.0), Some(40.0)) + ); +} + #[test] fn bound_move_records_the_binding_and_honors_the_index() { let mut state = sample(); diff --git a/crates/op-orchestrator/src/concurrent_tests.rs b/crates/op-orchestrator/src/concurrent_tests.rs index 7dba99e2d..824f08b0c 100644 --- a/crates/op-orchestrator/src/concurrent_tests.rs +++ b/crates/op-orchestrator/src/concurrent_tests.rs @@ -87,6 +87,7 @@ mod geometry_echo { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/design_system_tests.rs b/crates/op-orchestrator/src/design_system_tests.rs index 206416665..2748cc3a1 100644 --- a/crates/op-orchestrator/src/design_system_tests.rs +++ b/crates/op-orchestrator/src/design_system_tests.rs @@ -150,6 +150,7 @@ fn design_request_visual_ref_enabled_literal_compiles() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/plan.rs b/crates/op-orchestrator/src/plan.rs index 15e0e0184..4a7c75b56 100644 --- a/crates/op-orchestrator/src/plan.rs +++ b/crates/op-orchestrator/src/plan.rs @@ -186,6 +186,59 @@ pub fn build_fallback_plan(req: &DesignRequest) -> OrchestratorPlan { const WIDTH: f64 = 1200.0; const SECTION_HEIGHT: f64 = 360.0; + if let Some(context) = req + .continuation_context + .as_ref() + .filter(|context| !context.screen_names.is_empty()) + { + let fill = context + .background_color + .clone() + .unwrap_or_else(|| "#FFFFFF".into()); + let subtasks = context + .screen_names + .iter() + .enumerate() + .map(|(index, screen_name)| { + let id = format!("continuation-screen-{}", index + 1); + Subtask { + id: id.clone(), + label: screen_name.clone(), + region: Region { + width: context.screen_width, + height: context.screen_height, + }, + id_prefix: id, + parent_frame_id: None, + elements: Some(format!( + "the complete {screen_name} screen, continuing the existing product; reuse its established design system and shared navigation" + )), + screen: Some(screen_name.clone()), + generated_root_id: None, + existing_section_labels: None, + retry_feedback: None, + } + }) + .collect(); + return OrchestratorPlan { + root_frame: RootFrameSpec { + id: "continuation".into(), + name: "Continuation".into(), + width: context.screen_width, + height: context.screen_height, + layout: Some("vertical".into()), + gap: Some(0.0), + padding: Some(0.0), + fill: Some(vec![PlanFill { + kind: "solid".into(), + color: fill, + }]), + }, + subtasks, + style_guide_name: None, + }; + } + let preset = detect_design_type(&req.prompt); if preset.type_ == DesignType::Slides { return build_fallback_deck_plan(req, preset); @@ -507,6 +560,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -577,6 +631,39 @@ mod tests { assert_eq!(mobile.subtasks[1].label, "Main Content"); } + #[test] + fn fallback_continuation_keeps_promised_screens_and_existing_artboard() { + let mut request = req("继续生成 星图、观测计划、我的3个界面"); + request.continuation_context = Some(crate::types::ContinuationContext { + screen_width: 390.0, + screen_height: 844.0, + background_color: Some("#050508".into()), + screen_names: vec!["星图".into(), "观测计划".into(), "我的".into()], + }); + + let plan = build_fallback_plan(&request); + + assert_eq!( + (plan.root_frame.width, plan.root_frame.height), + (390.0, 844.0) + ); + assert_eq!( + plan.root_frame.first_solid_hex().as_deref(), + Some("#050508") + ); + assert_eq!( + plan.subtasks + .iter() + .filter_map(|subtask| subtask.screen.as_deref()) + .collect::>(), + ["星图", "观测计划", "我的"] + ); + assert!(plan + .subtasks + .iter() + .all(|subtask| subtask.label != "Section 1")); + } + // ── Task A1: Subtask.existing_section_labels ────────────────────────────── /// Subtask accepts existing_section_labels: None without breaking compilation. diff --git a/crates/op-orchestrator/src/plan_continuation_contract.rs b/crates/op-orchestrator/src/plan_continuation_contract.rs new file mode 100644 index 000000000..65b637e9c --- /dev/null +++ b/crates/op-orchestrator/src/plan_continuation_contract.rs @@ -0,0 +1,126 @@ +//! Normalize planned sibling screens against the live canvas contract. + +use std::collections::HashSet; + +use crate::plan::{OrchestratorPlan, PlanFill, Region, Subtask}; +use crate::types::DesignRequest; + +/// Make the live canvas contract authoritative for sibling-screen +/// continuations, even when planning returned syntactically valid but generic +/// desktop output. +pub(super) fn apply(plan: &mut OrchestratorPlan, req: &DesignRequest) -> bool { + let Some(context) = req.continuation_context.as_ref() else { + return false; + }; + if !context.screen_width.is_finite() + || !context.screen_height.is_finite() + || context.screen_width <= 0.0 + || context.screen_height <= 0.0 + { + return false; + } + + let mut screen_names = Vec::::new(); + for raw in &context.screen_names { + let name = raw.trim(); + if !name.is_empty() + && !screen_names + .iter() + .any(|existing| existing.eq_ignore_ascii_case(name)) + { + screen_names.push(name.to_string()); + } + } + if screen_names.is_empty() { + return false; + } + + plan.root_frame.width = context.screen_width; + plan.root_frame.height = context.screen_height; + if let Some(color) = context + .background_color + .as_deref() + .map(str::trim) + .filter(|color| !color.is_empty()) + { + plan.root_frame.fill = Some(vec![PlanFill { + kind: "solid".into(), + color: color.to_string(), + }]); + } + + // Preserve detailed planning only where it can be assigned to one of the + // exact promised screens. Generic/unknown sections are ambiguous in a + // multi-root continuation and used to collapse into a single Section 1 + // board, so drop them and synthesize one complete-screen task for every + // missing promise. + let mut buckets = vec![Vec::::new(); screen_names.len()]; + for mut subtask in std::mem::take(&mut plan.subtasks) { + let candidate = subtask.screen.as_deref().or_else(|| { + screen_names + .iter() + .any(|name| name.eq_ignore_ascii_case(subtask.label.trim())) + .then_some(subtask.label.as_str()) + }); + let Some(index) = candidate.and_then(|candidate| { + screen_names + .iter() + .position(|name| name.eq_ignore_ascii_case(candidate.trim())) + }) else { + continue; + }; + subtask.screen = Some(screen_names[index].clone()); + subtask.region = Region { + width: context.screen_width, + height: context.screen_height, + }; + // A planner-provided parent can point at its generic desktop root. + // Screen-group scaffolding will bind this task to the real sibling + // root after normalization, so never carry that stale parent through. + subtask.parent_frame_id = None; + buckets[index].push(subtask); + } + + let mut reconciled = Vec::new(); + let mut used_ids = buckets + .iter() + .flatten() + .map(|task| task.id.clone()) + .collect::>(); + for (index, (screen_name, mut tasks)) in screen_names + .into_iter() + .zip(buckets.into_iter()) + .enumerate() + { + if tasks.is_empty() { + let base_id = format!("continuation-screen-{}", index + 1); + let mut id = base_id.clone(); + let mut suffix = 2usize; + while used_ids.contains(&id) { + id = format!("{base_id}-{suffix}"); + suffix += 1; + } + tasks.push(Subtask { + id: id.clone(), + label: screen_name.clone(), + region: Region { + width: context.screen_width, + height: context.screen_height, + }, + id_prefix: id, + parent_frame_id: None, + elements: Some(format!( + "the complete {screen_name} screen, continuing the existing product; reuse its established design system and shared navigation" + )), + screen: Some(screen_name), + generated_root_id: None, + existing_section_labels: None, + retry_feedback: None, + }); + } + used_ids.extend(tasks.iter().map(|task| task.id.clone())); + reconciled.extend(tasks); + } + plan.subtasks = reconciled; + true +} diff --git a/crates/op-orchestrator/src/plan_normalize.rs b/crates/op-orchestrator/src/plan_normalize.rs index 5d1e89f46..9b79a78d3 100644 --- a/crates/op-orchestrator/src/plan_normalize.rs +++ b/crates/op-orchestrator/src/plan_normalize.rs @@ -7,16 +7,22 @@ use crate::dashboard_columns::{ infer_dashboard_section_height, infer_dashboard_section_width, is_dashboard_like_prompt, }; -use crate::plan::{OrchestratorPlan, Region, Subtask}; +use crate::plan::OrchestratorPlan; use crate::types::DesignRequest; #[path = "plan_home_intent.rs"] mod plan_home_intent; -use plan_home_intent::plan_is_app_home_screen; + +#[path = "plan_normalize_nav.rs"] +mod plan_normalize_nav; +use plan_normalize_nav::{ensure_requested_bottom_nav_subtask, is_bottom_nav_subtask}; #[path = "plan_normalize_dimensions.rs"] mod plan_normalize_dimensions; +#[path = "plan_continuation_contract.rs"] +mod plan_continuation_contract; + // multiscreen-fanout-break fix (item A) — screen-grouping tests, split out // to keep this file's inline `mod tests` from crossing the 800-line cap. #[cfg(test)] @@ -78,86 +84,6 @@ fn strip_status_bar_fragments(text: &str) -> Option { } } -fn prompt_requests_bottom_nav(prompt: &str) -> bool { - let hay = prompt.to_lowercase(); - if prompt_forbids_bottom_nav(prompt) { - return false; - } - hay.contains("bottom nav") - || hay.contains("bottom navigation") - || hay.contains("bottom tab") - || hay.contains("bottom-tab") - || hay.contains("tab bar") - || hay.contains("tabbar") - || hay.contains("底部导航") - || hay.contains("底栏") -} - -fn prompt_forbids_bottom_nav(prompt: &str) -> bool { - let hay = prompt.to_lowercase(); - hay.contains("no bottom nav") - || hay.contains("without bottom nav") - || hay.contains("without bottom navigation") - || hay.contains("不要底部导航") - || hay.contains("不需要底部导航") -} - -fn is_bottom_nav_subtask(st: &Subtask) -> bool { - let hay = format!( - "{} {} {}", - st.id.to_lowercase(), - st.label.to_lowercase(), - st.elements.as_deref().unwrap_or_default().to_lowercase() - ); - hay.contains("bottom nav") - || hay.contains("bottom-navigation") - || hay.contains("bottom navigation") - || hay.contains("bottom tab") - || hay.contains("bottom-tab") - || hay.contains("tab bar") - || hay.contains("tabbar") - || hay.contains("bottom-tab-bar") -} - -fn ensure_requested_bottom_nav_subtask(plan: &mut OrchestratorPlan, req: &DesignRequest) { - if prompt_forbids_bottom_nav(&req.prompt) { - plan.subtasks.retain(|st| !is_bottom_nav_subtask(st)); - return; - } - if plan.subtasks.iter().any(is_bottom_nav_subtask) { - return; - } - // Two ways in: the prompt asked for it, OR this is an app HOME/main - // screen — a multi-section mobile plan whose root/screen reads as a - // home/feed — where a bottom tab bar is anatomy, not an option (a - // glm "Food App Home" planned 4 content sections and simply skipped - // the navbar the teaching asked for; plan-level completeness is the - // deterministic backstop). Single-task flows (<3 sections) never - // qualify. - if !prompt_requests_bottom_nav(&req.prompt) && !plan_is_app_home_screen(plan) { - return; - } - - plan.subtasks.push(Subtask { - id: "bottom-navigation".into(), - label: "Bottom Navigation".into(), - region: Region { - width: plan.root_frame.width, - height: 78.0, - }, - id_prefix: String::new(), - parent_frame_id: None, - elements: Some( - "bottom tab bar with this app's own 3-5 top-level destinations as icon + label tabs (choose tabs that fit the product, not a fixed Home/Search/Orders set); role bottom-tab-bar; full-width surface matching the page; transparent tab item frames; active state via accent icon/label color, not filled pills" - .into(), - ), - screen: None, - generated_root_id: None, - existing_section_labels: None, - retry_feedback: None, - }); -} - /// 就地规范化 `plan`: /// - 一次性判定 `is_mobile`(根 frame 宽度); /// - 移动端剔除 plan 自带的状态栏 subtask(状态栏改由 scaffold 注入); @@ -167,8 +93,11 @@ fn ensure_requested_bottom_nav_subtask(plan: &mut OrchestratorPlan, req: &Design /// LLM 值,超出则取推断值 —— 忠实 TS `normalizeOrchestratorPlan` /// `orchestrator.ts:259-272`)。 pub fn normalize(plan: &mut OrchestratorPlan, req: &DesignRequest) -> NormInfo { - let preserve_requested_root_height = + let requested_dimensions_applied = plan_normalize_dimensions::apply_requested_root_dimensions(plan, req); + let continuation_contract_applied = plan_continuation_contract::apply(plan, req); + let preserve_requested_root_height = + requested_dimensions_applied || continuation_contract_applied; // A deck's board is the projector: 16:9, fixed, and never resized to fit // its content. Without this, `adjust_root_height_to_content` grew a cover @@ -247,7 +176,10 @@ pub fn normalize(plan: &mut OrchestratorPlan, req: &DesignRequest) -> NormInfo { for st in &mut plan.subtasks { st.id_prefix = st.id.clone(); - if dashboard_like { + // Continuation regions describe complete sibling artboards, not + // dashboard sections inside one root. Their live-canvas contract is + // authoritative and must not be shrunk again by section heuristics. + if dashboard_like && !continuation_contract_applied { let inferred_width = infer_dashboard_section_width(st, root_width); let inferred_height = infer_dashboard_section_height(st); diff --git a/crates/op-orchestrator/src/plan_normalize_dimensions_tests.rs b/crates/op-orchestrator/src/plan_normalize_dimensions_tests.rs index 0db8987ec..30f6b4551 100644 --- a/crates/op-orchestrator/src/plan_normalize_dimensions_tests.rs +++ b/crates/op-orchestrator/src/plan_normalize_dimensions_tests.rs @@ -10,6 +10,7 @@ fn request(prompt: &str) -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/plan_normalize_nav.rs b/crates/op-orchestrator/src/plan_normalize_nav.rs new file mode 100644 index 000000000..3e26aefcd --- /dev/null +++ b/crates/op-orchestrator/src/plan_normalize_nav.rs @@ -0,0 +1,86 @@ +//! Bottom-navigation normalization for mobile plans. + +use crate::plan::{OrchestratorPlan, Region, Subtask}; +use crate::types::DesignRequest; + +use super::plan_home_intent::plan_is_app_home_screen; + +fn prompt_requests_bottom_nav(prompt: &str) -> bool { + let hay = prompt.to_lowercase(); + if prompt_forbids_bottom_nav(prompt) { + return false; + } + hay.contains("bottom nav") + || hay.contains("bottom navigation") + || hay.contains("bottom tab") + || hay.contains("bottom-tab") + || hay.contains("tab bar") + || hay.contains("tabbar") + || hay.contains("底部导航") + || hay.contains("底栏") +} + +fn prompt_forbids_bottom_nav(prompt: &str) -> bool { + let hay = prompt.to_lowercase(); + hay.contains("no bottom nav") + || hay.contains("without bottom nav") + || hay.contains("without bottom navigation") + || hay.contains("不要底部导航") + || hay.contains("不需要底部导航") +} + +pub(super) fn is_bottom_nav_subtask(st: &Subtask) -> bool { + let hay = format!( + "{} {} {}", + st.id.to_lowercase(), + st.label.to_lowercase(), + st.elements.as_deref().unwrap_or_default().to_lowercase() + ); + hay.contains("bottom nav") + || hay.contains("bottom-navigation") + || hay.contains("bottom navigation") + || hay.contains("bottom tab") + || hay.contains("bottom-tab") + || hay.contains("tab bar") + || hay.contains("tabbar") + || hay.contains("bottom-tab-bar") +} + +pub(super) fn ensure_requested_bottom_nav_subtask( + plan: &mut OrchestratorPlan, + req: &DesignRequest, +) { + if prompt_forbids_bottom_nav(&req.prompt) { + plan.subtasks.retain(|st| !is_bottom_nav_subtask(st)); + return; + } + if plan.subtasks.iter().any(is_bottom_nav_subtask) { + return; + } + // Two ways in: the prompt asked for it, OR this is an app HOME/main + // screen — a multi-section mobile plan whose root/screen reads as a + // home/feed — where a bottom tab bar is anatomy, not an option. Single- + // task flows (<3 sections) never qualify. + if !prompt_requests_bottom_nav(&req.prompt) && !plan_is_app_home_screen(plan) { + return; + } + + plan.subtasks.push(Subtask { + id: "bottom-navigation".into(), + label: "Bottom Navigation".into(), + region: Region { + width: plan.root_frame.width, + height: 78.0, + }, + id_prefix: String::new(), + parent_frame_id: None, + elements: Some( + "bottom tab bar with this app's own 3-5 top-level destinations as icon + label tabs (choose tabs that fit the product, not a fixed Home/Search/Orders set); role bottom-tab-bar; full-width surface matching the page; transparent tab item frames; active state via accent icon/label color, not filled pills" + .into(), + ), + screen: None, + generated_root_id: None, + existing_section_labels: None, + retry_feedback: None, + }); +} diff --git a/crates/op-orchestrator/src/plan_normalize_nav_tests.rs b/crates/op-orchestrator/src/plan_normalize_nav_tests.rs index 6cf5398e9..2608e3544 100644 --- a/crates/op-orchestrator/src/plan_normalize_nav_tests.rs +++ b/crates/op-orchestrator/src/plan_normalize_nav_tests.rs @@ -8,6 +8,7 @@ fn request(prompt: &str) -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/plan_normalize_screen_groups_tests.rs b/crates/op-orchestrator/src/plan_normalize_screen_groups_tests.rs index ae115ed2b..e4a290883 100644 --- a/crates/op-orchestrator/src/plan_normalize_screen_groups_tests.rs +++ b/crates/op-orchestrator/src/plan_normalize_screen_groups_tests.rs @@ -5,8 +5,9 @@ //! `req` / `subtask` / `plan` exactly. use super::*; +use crate::plan::PlanFill; use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask}; -use crate::types::DesignRequest; +use crate::types::{ContinuationContext, DesignRequest}; fn req() -> DesignRequest { DesignRequest { @@ -15,6 +16,7 @@ fn req() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -58,6 +60,19 @@ fn plan(width: f64, subtasks: Vec) -> OrchestratorPlan { } } +fn continuation_req() -> DesignRequest { + DesignRequest { + prompt: "Continue with the star map, observation plan, and profile screens".into(), + continuation_context: Some(ContinuationContext { + screen_width: 390.0, + screen_height: 844.0, + background_color: Some("#050508".into()), + screen_names: vec!["星图".into(), "观测计划".into(), "我的".into()], + }), + ..Default::default() + } +} + /// multiscreen-fanout-break regression lock: ≥2 distinct `screen` labels /// must NOT collapse onto the shared `root_id` — each group gets its own /// placeholder id, and every group's subtasks share it. @@ -124,3 +139,103 @@ fn normalize_all_same_screen_label_keeps_single_root() { assert_eq!(st.parent_frame_id.as_deref(), Some("root")); } } + +#[test] +fn continuation_contract_overrides_valid_but_wrong_screen_plan() { + let names = ["星图", "观测计划", "我的"]; + let mut tasks = names + .iter() + .enumerate() + .map(|(index, name)| { + let mut task = subtask_with_screen( + &format!("screen-{}", index + 1), + &format!("{name} detail"), + Some(name), + ); + task.region = Region { + width: 1512.0 - index as f64 * 100.0, + height: 982.0 - index as f64 * 50.0, + }; + task.parent_frame_id = Some("giant-generic-root".into()); + task + }) + .collect::>(); + let mut p = plan(1512.0, std::mem::take(&mut tasks)); + p.root_frame.height = 982.0; + p.root_frame.fill = Some(vec![PlanFill { + kind: "solid".into(), + color: "#16002E".into(), + }]); + + normalize(&mut p, &continuation_req()); + + assert_eq!((p.root_frame.width, p.root_frame.height), (390.0, 844.0)); + assert_eq!(p.root_frame.first_solid_hex().as_deref(), Some("#050508")); + assert_eq!( + p.subtasks + .iter() + .map(|task| task.screen.as_deref().unwrap_or_default()) + .collect::>(), + names + ); + let parents = p + .subtasks + .iter() + .map(|task| { + assert_eq!((task.region.width, task.region.height), (390.0, 844.0)); + let parent = task.parent_frame_id.as_deref().expect("group parent"); + assert_ne!(parent, "giant-generic-root"); + parent + }) + .collect::>(); + assert_eq!(parents.len(), 3, "each exact screen gets its own root"); +} + +#[test] +fn continuation_contract_fans_out_a_valid_generic_plan_to_exact_screens() { + let mut generic = subtask_with_screen("section-1", "Section 1", None); + generic.region = Region { + width: 1512.0, + height: 982.0, + }; + generic.parent_frame_id = Some("giant-generic-root".into()); + let mut p = plan(1512.0, vec![generic]); + p.root_frame.height = 982.0; + + normalize(&mut p, &continuation_req()); + + assert_eq!(p.subtasks.len(), 3); + assert_eq!( + p.subtasks + .iter() + .map(|task| task.screen.as_deref().unwrap_or_default()) + .collect::>(), + ["星图", "观测计划", "我的"] + ); + assert!(p.subtasks.iter().all(|task| { + (task.region.width, task.region.height) == (390.0, 844.0) + && task.label != "Section 1" + && task.parent_frame_id.as_deref() != Some("giant-generic-root") + })); +} + +#[test] +fn continuation_artboards_are_not_shrunk_by_dashboard_section_heuristics() { + let mut generic = subtask_with_screen("section-1", "Dashboard Section", None); + generic.region = Region { + width: 1512.0, + height: 982.0, + }; + let mut p = plan(1512.0, vec![generic]); + p.root_frame.height = 982.0; + let mut request = continuation_req(); + request.prompt = "Continue the mobile observatory dashboard screens".into(); + + normalize(&mut p, &request); + + assert_eq!(p.subtasks.len(), 3); + assert!(p + .subtasks + .iter() + .all(|task| (task.region.width, task.region.height) == (390.0, 844.0))); +} diff --git a/crates/op-orchestrator/src/plan_repair_tests.rs b/crates/op-orchestrator/src/plan_repair_tests.rs index 83390bf95..7d1a605d9 100644 --- a/crates/op-orchestrator/src/plan_repair_tests.rs +++ b/crates/op-orchestrator/src/plan_repair_tests.rs @@ -243,6 +243,7 @@ fn req(prompt: &str) -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -271,6 +272,7 @@ fn req_with_design_md(prompt: &str) -> DesignRequest { generation_notes: None, }), concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, diff --git a/crates/op-orchestrator/src/prompt_deck_skill_tests.rs b/crates/op-orchestrator/src/prompt_deck_skill_tests.rs index 53fbd18b0..19f2c7dd6 100644 --- a/crates/op-orchestrator/src/prompt_deck_skill_tests.rs +++ b/crates/op-orchestrator/src/prompt_deck_skill_tests.rs @@ -61,6 +61,7 @@ fn deck_request(model: &str) -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/prompt_planning_tests.rs b/crates/op-orchestrator/src/prompt_planning_tests.rs index 4820cd172..17cfe0ba9 100644 --- a/crates/op-orchestrator/src/prompt_planning_tests.rs +++ b/crates/op-orchestrator/src/prompt_planning_tests.rs @@ -89,6 +89,50 @@ fn subagent_prompt_carries_subtask_and_script_format() { ); } +#[test] +fn generation_protocols_require_first_class_interactive_controls() { + for kind in [ + "text_input", + "text_area", + "select", + "switch", + "checkbox", + "slider", + "radio_group", + "number_input", + "progress", + "tabs", + ] { + assert!( + SCRIPT_FORMAT.contains(kind), + "script protocol must list native widget `{kind}`" + ); + assert!( + NODE_FORMAT.contains(kind), + "legacy JSONL protocol must list native widget `{kind}`" + ); + } + for contract in [ + "options:[{value,label}]", + "checked", + "min/max/step/value", + "fill, stroke, and cornerRadius", + "fill is the active/accent paint", + "stroke.fill is the inactive track/border paint", + ] { + assert!( + SCRIPT_FORMAT.contains(contract), + "script protocol lost interactive contract {contract:?}" + ); + assert!( + NODE_FORMAT.contains(contract), + "legacy JSONL protocol lost interactive contract {contract:?}" + ); + } + assert!(SCRIPT_FORMAT.contains("never a frame/rectangle mockup with a role marker")); + assert!(NODE_FORMAT.contains("Never generate a frame/rectangle mockup with a role marker")); +} + /// The reduced-complexity retry rung keeps script-gen; only its skill set is /// narrowed. #[test] @@ -271,6 +315,7 @@ fn subagent_prompt_reduced_complexity_basic_is_shorter_than_full() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -400,6 +445,7 @@ fn subagent_prompt_basic_tier_reduced_retry_drops_jsonl_format_skills() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, @@ -451,6 +497,7 @@ fn subagent_prompt_basic_mobile_food_keeps_mobile_app_skill() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/prompt_resolved_style_tests.rs b/crates/op-orchestrator/src/prompt_resolved_style_tests.rs index 5cdc8b0fd..036f6ee57 100644 --- a/crates/op-orchestrator/src/prompt_resolved_style_tests.rs +++ b/crates/op-orchestrator/src/prompt_resolved_style_tests.rs @@ -26,6 +26,7 @@ fn req() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/prompt_subagent_content_tests.rs b/crates/op-orchestrator/src/prompt_subagent_content_tests.rs index 86c115c65..e8679783e 100644 --- a/crates/op-orchestrator/src/prompt_subagent_content_tests.rs +++ b/crates/op-orchestrator/src/prompt_subagent_content_tests.rs @@ -103,6 +103,7 @@ fn subagent_prompt_honors_explicit_radius_and_spacing_numbers() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, @@ -162,6 +163,7 @@ fn mobile_food_prompt_avoids_fixed_food_template() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, @@ -245,6 +247,7 @@ fn chinese_mobile_food_prompt_carries_language_consistency_rule() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/prompt_tests.rs b/crates/op-orchestrator/src/prompt_tests.rs index 65a659efd..77e6e2773 100644 --- a/crates/op-orchestrator/src/prompt_tests.rs +++ b/crates/op-orchestrator/src/prompt_tests.rs @@ -34,6 +34,7 @@ fn req() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, diff --git a/crates/op-orchestrator/src/prompt_timeout_feedback_tests.rs b/crates/op-orchestrator/src/prompt_timeout_feedback_tests.rs index 64fe5d587..6451c0d4a 100644 --- a/crates/op-orchestrator/src/prompt_timeout_feedback_tests.rs +++ b/crates/op-orchestrator/src/prompt_timeout_feedback_tests.rs @@ -55,6 +55,7 @@ fn orchestrator_prompt_long_prompt_has_larger_timeout_than_short() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -68,6 +69,7 @@ fn orchestrator_prompt_long_prompt_has_larger_timeout_than_short() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -92,6 +94,7 @@ fn orchestrator_prompt_multiplier_applied() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -136,6 +139,7 @@ fn subagent_prompt_long_prompt_has_larger_timeout() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -148,6 +152,7 @@ fn subagent_prompt_long_prompt_has_larger_timeout() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -185,6 +190,7 @@ fn subagent_prompt_basic_tier_clamps_soft_timeouts() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -420,6 +426,7 @@ fn subtask_intent_includes_prompt_label_and_hints() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/retry_subtask_tests.rs b/crates/op-orchestrator/src/retry_subtask_tests.rs index dd0e1a8ef..1e6fceb8f 100644 --- a/crates/op-orchestrator/src/retry_subtask_tests.rs +++ b/crates/op-orchestrator/src/retry_subtask_tests.rs @@ -14,6 +14,7 @@ fn design_request() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/run_retry_feedback_tests.rs b/crates/op-orchestrator/src/run_retry_feedback_tests.rs index e388d350a..104518e1b 100644 --- a/crates/op-orchestrator/src/run_retry_feedback_tests.rs +++ b/crates/op-orchestrator/src/run_retry_feedback_tests.rs @@ -41,6 +41,7 @@ fn request() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/run_tests.rs b/crates/op-orchestrator/src/run_tests.rs index 1c677e0e9..1209bb5ae 100644 --- a/crates/op-orchestrator/src/run_tests.rs +++ b/crates/op-orchestrator/src/run_tests.rs @@ -25,6 +25,7 @@ fn req() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -42,6 +43,7 @@ fn req_standard() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -62,6 +64,7 @@ fn req_basic() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, diff --git a/crates/op-orchestrator/src/run_tests_b4.rs b/crates/op-orchestrator/src/run_tests_b4.rs index da8c8349a..6b7e45c5b 100644 --- a/crates/op-orchestrator/src/run_tests_b4.rs +++ b/crates/op-orchestrator/src/run_tests_b4.rs @@ -83,6 +83,7 @@ fn req_append(live_target_id: &str) -> DesignRequest { DesignRequest { prompt: "add a pricing section".into(), concurrency: 1, + continuation_context: None, append_context: Some(AppendContext { target_parent_id: live_target_id.into(), target_width: 1200.0, @@ -97,6 +98,7 @@ fn req_append_concurrent(live_target_id: &str) -> DesignRequest { DesignRequest { prompt: "add more screens".into(), concurrency: 4, + continuation_context: None, append_context: Some(AppendContext { target_parent_id: live_target_id.into(), target_width: 390.0, @@ -344,6 +346,7 @@ fn non_append_mode_takes_normal_sequential_path() { let req = DesignRequest { prompt: "a landing page".into(), concurrency: 1, + continuation_context: None, append_context: None, // no append context ..Default::default() }; @@ -395,6 +398,7 @@ fn non_append_mode_resolves_scaffold_root_when_empty_frame_is_replaced() { let req = DesignRequest { prompt: "a mobile food app".into(), concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, ..Default::default() @@ -476,6 +480,7 @@ fn append_mode_wins_over_dashboard_branch() { let req = DesignRequest { prompt: "an analytics admin dashboard".into(), concurrency: 1, + continuation_context: None, append_context: Some(AppendContext { target_parent_id: live_id.clone(), target_width: 1440.0, @@ -649,6 +654,7 @@ fn append_cleanup_leaves_preexisting_nav_surface_untouched() { let req = DesignRequest { prompt: "add a hero section".into(), concurrency: 1, + continuation_context: None, append_context: Some(AppendContext { target_parent_id: live_target_id.clone(), target_width: 390.0, @@ -727,6 +733,7 @@ fn fresh_doc_cleanup_still_runs_over_scaffold_root() { let req = DesignRequest { prompt: "a landing page".into(), concurrency: 1, + continuation_context: None, append_context: None, // fresh doc — non-append path ..Default::default() }; diff --git a/crates/op-orchestrator/src/run_tests_d1.rs b/crates/op-orchestrator/src/run_tests_d1.rs index 4b35a2a73..af69ec46a 100644 --- a/crates/op-orchestrator/src/run_tests_d1.rs +++ b/crates/op-orchestrator/src/run_tests_d1.rs @@ -93,6 +93,7 @@ fn req_validation_enabled() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -108,6 +109,7 @@ fn req_validation_disabled() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, @@ -329,6 +331,7 @@ fn dashboard_validation_enabled_emits_validation_done() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, @@ -391,6 +394,7 @@ fn dashboard_validation_disabled_no_validation_events() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, @@ -436,6 +440,7 @@ fn concurrent_validation_enabled_emits_validation_done() { provider: None, design_md: None, concurrency: 2, + continuation_context: None, append_context: None, validation_enabled: true, @@ -498,6 +503,7 @@ fn concurrent_validation_disabled_no_validation_events() { provider: None, design_md: None, concurrency: 2, + continuation_context: None, append_context: None, validation_enabled: false, diff --git a/crates/op-orchestrator/src/run_tests_f5.rs b/crates/op-orchestrator/src/run_tests_f5.rs index 6c0e8a0db..d8502f730 100644 --- a/crates/op-orchestrator/src/run_tests_f5.rs +++ b/crates/op-orchestrator/src/run_tests_f5.rs @@ -186,6 +186,7 @@ fn append_does_not_mutate_preexisting_styled_node() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: Some(AppendContext { target_parent_id: live_target_id.clone(), target_width: 390.0, diff --git a/crates/op-orchestrator/src/run_tests_screen_groups.rs b/crates/op-orchestrator/src/run_tests_screen_groups.rs index 67e66a4e4..23719115f 100644 --- a/crates/op-orchestrator/src/run_tests_screen_groups.rs +++ b/crates/op-orchestrator/src/run_tests_screen_groups.rs @@ -30,6 +30,7 @@ fn req() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, @@ -265,6 +266,57 @@ fn find_root_by_name<'a>(roots: &'a [PenNode], name: &str) -> &'a PenNode { .unwrap_or_else(|| panic!("no root named {name} among {roots:?}")) } +#[test] +fn planning_failure_continuation_builds_three_inherited_sibling_screens() { + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text("not a plan".into()), + ScriptResponse::Text("still not a plan".into()), + ScriptResponse::Text(node_json("星图")), + ScriptResponse::Text(node_json("观测计划")), + ScriptResponse::Text(node_json("我的")), + ]); + let mut sink = VecDocSink::new(); + sink.state.active_children_mut().clear(); + sink.state.active_children_mut().push( + serde_json::from_value(serde_json::json!({ + "type": "frame", "id": "home", "name": "Nocturne 今夜", + "width": 390, "height": 844, + "fill": [{ "type": "solid", "color": "#050508" }], + "children": [{ "type": "text", "id": "title", "content": "今夜天空" }] + })) + .expect("existing home screen"), + ); + let mut request = req(); + request.prompt = "继续生成 星图、观测计划、我的3个界面".into(); + request.continuation_context = Some(crate::types::ContinuationContext { + screen_width: 390.0, + screen_height: 844.0, + background_color: Some("#050508".into()), + screen_names: vec!["星图".into(), "观测计划".into(), "我的".into()], + }); + + futures::executor::block_on(Orchestrator::new().run( + request, + &mut sink, + &llm, + &mut |_| {}, + &AbortFlag::new(), + &stub_providers(), + )) + .expect("fallback continuation run"); + + let generated = &sink.state.active_children()[1..]; + assert_eq!(generated.len(), 3); + for name in ["星图", "观测计划", "我的"] { + let root = find_root_by_name(generated, name); + assert_eq!( + (root.width_px(), root.height_px()), + (Some(390.0), Some(844.0)) + ); + assert_eq!(op_editor_core::first_solid_fill_hex(root), Some("#050508")); + } +} + // Cluster test modules — this file keeps the shared fixtures. #[path = "run_tests_screen_concurrency.rs"] mod concurrency_tests; diff --git a/crates/op-orchestrator/src/spawn_concurrent_tests.rs b/crates/op-orchestrator/src/spawn_concurrent_tests.rs index 7f38c868d..e76ae29c5 100644 --- a/crates/op-orchestrator/src/spawn_concurrent_tests.rs +++ b/crates/op-orchestrator/src/spawn_concurrent_tests.rs @@ -21,6 +21,7 @@ fn make_req() -> crate::types::DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/subagent_reveal_tests.rs b/crates/op-orchestrator/src/subagent_reveal_tests.rs index 0abf87a2f..0c4045147 100644 --- a/crates/op-orchestrator/src/subagent_reveal_tests.rs +++ b/crates/op-orchestrator/src/subagent_reveal_tests.rs @@ -16,6 +16,7 @@ fn f2_request() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/subagent_tests.rs b/crates/op-orchestrator/src/subagent_tests.rs index 0c09d7f8b..9c72b2f02 100644 --- a/crates/op-orchestrator/src/subagent_tests.rs +++ b/crates/op-orchestrator/src/subagent_tests.rs @@ -15,6 +15,7 @@ fn req() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, diff --git a/crates/op-orchestrator/src/types.rs b/crates/op-orchestrator/src/types.rs index f47c2ad7f..809347283 100644 --- a/crates/op-orchestrator/src/types.rs +++ b/crates/op-orchestrator/src/types.rs @@ -639,6 +639,21 @@ pub struct AppendContext { pub is_mobile: bool, } +/// Existing-canvas facts for a request that creates sibling screens. +/// +/// Unlike [`AppendContext`], this does not target an existing parent. It +/// carries the artboard contract and the exact screens the user promised so +/// planning failure can still fan out into correctly-sized top-level roots. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContinuationContext { + pub screen_width: f64, + pub screen_height: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_color: Option, + pub screen_names: Vec, +} + /// 编排器输入 —— 一次设计请求。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -656,6 +671,9 @@ pub struct DesignRequest { /// Port of `AIDesignRequest.context.appendContext` in `ai-types.ts:51`. #[serde(default, skip_serializing_if = "Option::is_none")] pub append_context: Option, + /// Sibling-screen continuation facts derived from the live canvas. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub continuation_context: Option, /// 是否启用后生成视觉校验循环(S3c)。 /// 对应 TS `VALIDATION_ENABLED` flag(默认 `true`)。 /// host 可将其设为 `false` 以跳过整个视觉校验阶段。 @@ -694,6 +712,7 @@ impl Default for DesignRequest { design_md: None, concurrency: 1, append_context: None, + continuation_context: None, validation_enabled: default_validation_enabled(), visual_ref_enabled: default_visual_ref_enabled(), pinned_style_guide: None, diff --git a/crates/op-orchestrator/src/types_tests.rs b/crates/op-orchestrator/src/types_tests.rs index 44024bcfa..01252e1a0 100644 --- a/crates/op-orchestrator/src/types_tests.rs +++ b/crates/op-orchestrator/src/types_tests.rs @@ -111,6 +111,21 @@ fn append_context_serde_round_trip() { assert!(back.is_mobile); } +#[test] +fn continuation_context_serde_round_trip() { + let context = ContinuationContext { + screen_width: 390.0, + screen_height: 844.0, + background_color: Some("#050508".into()), + screen_names: vec!["星图".into(), "观测计划".into(), "我的".into()], + }; + let json = serde_json::to_string(&context).expect("serialize"); + assert!(json.contains("screenWidth")); + assert!(json.contains("backgroundColor")); + let back: ContinuationContext = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, context); +} + /// DesignRequest accepts append_context: None without breaking compilation. #[test] fn design_request_append_context_none_compiles() { @@ -120,6 +135,7 @@ fn design_request_append_context_none_compiles() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, @@ -143,6 +159,7 @@ fn design_request_append_context_some_compiles() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: Some(ctx), validation_enabled: true, visual_ref_enabled: false, @@ -160,6 +177,7 @@ fn design_request_append_context_omitted_from_json_when_none() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, visual_ref_enabled: false, @@ -434,6 +452,7 @@ fn design_request_validation_enabled_serde_roundtrip() { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/validation_tests_c2.rs b/crates/op-orchestrator/src/validation_tests_c2.rs index 92fab7902..4a23df6be 100644 --- a/crates/op-orchestrator/src/validation_tests_c2.rs +++ b/crates/op-orchestrator/src/validation_tests_c2.rs @@ -27,6 +27,7 @@ fn make_request(validation_enabled: bool) -> DesignRequest { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled, visual_ref_enabled: false, diff --git a/crates/op-orchestrator/src/variables.rs b/crates/op-orchestrator/src/variables.rs index 984ab5bab..f168eec92 100644 --- a/crates/op-orchestrator/src/variables.rs +++ b/crates/op-orchestrator/src/variables.rs @@ -198,6 +198,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: true, diff --git a/crates/op-smoke/src/loop_seed.rs b/crates/op-smoke/src/loop_seed.rs index b76a0cd0c..84e92b3c0 100644 --- a/crates/op-smoke/src/loop_seed.rs +++ b/crates/op-smoke/src/loop_seed.rs @@ -78,6 +78,7 @@ pub fn build_seed_command(prompt: &str) -> Result // concurrency 1 ⇒ the planner's simplest single-screen shape; the // seed path never engages the concurrent / dashboard branches. concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, @@ -165,6 +166,7 @@ pub fn seed_system_prompt_suffix(prompt: &str) -> String { provider: None, design_md: None, concurrency: 1, + continuation_context: None, append_context: None, validation_enabled: false, visual_ref_enabled: false, diff --git a/crates/op-smoke/src/main.rs b/crates/op-smoke/src/main.rs index 9adcbd58a..a520a439d 100644 --- a/crates/op-smoke/src/main.rs +++ b/crates/op-smoke/src/main.rs @@ -613,6 +613,7 @@ async fn main() -> std::process::ExitCode { model: Some(model), provider: None, design_md: sink.state.doc.design_md.clone(), + continuation_context: None, append_context: None, concurrency: std::env::var("OPENPENCIL_SMOKE_CONCURRENCY") .ok()