fix(agent): inherit artboards for sibling screen continuations

This commit is contained in:
Fini 2026-08-07 01:59:01 +08:00
parent 0b79902570
commit bf93a459b1
59 changed files with 1299 additions and 239 deletions

View file

@ -423,6 +423,7 @@ impl EditorCommand {
C::BatchInsert { .. }
| C::InsertSubtree { .. }
| C::InsertAuthoredSubtree { .. }
| C::InsertAuthoredSubtreePreservingRoots { .. }
| C::RefineDesign { .. }
| C::Batch { .. }
| C::ReplaceAllMatchingProperties { .. }

View file

@ -258,6 +258,18 @@ pub enum EditorCommand {
/// or legacy page index.
page_id: Option<String>,
},
/// 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<PenNode>,
/// `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<String>,
},
/// 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.

View file

@ -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,

View file

@ -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<AlignAction> {
@ -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<PenNode>,
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 {

View file

@ -13,16 +13,37 @@ impl EditorState {
&mut self,
nodes: Vec<PenNode>,
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<PenNode>,
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<PenNode>,
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

View file

@ -78,6 +78,7 @@ fn batchable(cmd: &EditorCommand) -> bool {
| C::BatchInsert { .. }
| C::InsertSubtree { .. }
| C::InsertAuthoredSubtree { .. }
| C::InsertAuthoredSubtreePreservingRoots { .. }
| C::RefineDesign { .. }
| C::SetVariableScalar { .. }
| C::CreateVariable { .. }

View file

@ -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<AppendContext>,
) -> 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<ContinuationContext> {
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]

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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,

View file

@ -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<String> {
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<Vec<String>> {
let parts = targets
.split(separator)
.map(normalize_target_name)
.collect::<Option<Vec<_>>>()?;
(parts.len() >= 2).then_some(parts)
}
fn named_list_names(targets: &str) -> Option<Vec<String>> {
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<Vec<String>> {
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<String> {
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() {

View file

@ -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;

View file

@ -11,6 +11,15 @@ pub enum RootSeedTarget {
Desktop,
}
#[derive(Debug, Clone, PartialEq)]
struct RootSeedProfile {
target: RootSeedTarget,
width: f64,
height: f64,
background_color: Option<String>,
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<String>,
allow_existing_single_root: bool,
) -> Option<String> {
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<String>,
target: RootSeedTarget,
profile: &RootSeedProfile,
allow_existing_single_root: bool,
) -> Option<String> {
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<String>,
) -> 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<String>,
allow_existing_single_root: bool,
) -> Vec<usize> {
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::<Vec<_>>();
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()
}
}

View file

@ -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"
);
}
}

View file

@ -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::<Vec<_>>(),
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

View file

@ -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,

View file

@ -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,

View file

@ -481,6 +481,7 @@ fn stream_new_design_route<W: Write>(
model: model.clone(),
provider: None,
design_md: snapshot.doc.design_md.clone(),
continuation_context: None,
append_context,
concurrency: req
.agent_team_size

View file

@ -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<usize>,
/// 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<String>,
}
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(),

View file

@ -174,14 +174,6 @@ pub(crate) fn with_page_id(cmd: EditorCommand, page_id: Option<String>) -> 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<usize> {

View file

@ -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::<Vec<_>>(),
["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();

View file

@ -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,

View file

@ -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,

View file

@ -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::<Vec<_>>(),
["星图", "观测计划", "我的"]
);
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.

View file

@ -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::<String>::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::<Subtask>::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::<HashSet<_>>();
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
}

View file

@ -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<String> {
}
}
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);

View file

@ -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,

View file

@ -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,
});
}

View file

@ -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,

View file

@ -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<Subtask>) -> 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::<Vec<_>>();
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::<Vec<_>>(),
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::<std::collections::HashSet<_>>();
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::<Vec<_>>(),
["星图", "观测计划", "我的"]
);
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)));
}

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -34,6 +34,7 @@ fn req() -> DesignRequest {
provider: None,
design_md: None,
concurrency: 1,
continuation_context: None,
append_context: None,
validation_enabled: true,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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()
};

View file

@ -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,

View file

@ -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,

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -15,6 +15,7 @@ fn req() -> DesignRequest {
provider: None,
design_md: None,
concurrency: 1,
continuation_context: None,
append_context: None,
validation_enabled: true,

View file

@ -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<String>,
pub screen_names: Vec<String>,
}
/// 编排器输入 —— 一次设计请求。
#[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<AppendContext>,
/// Sibling-screen continuation facts derived from the live canvas.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub continuation_context: Option<ContinuationContext>,
/// 是否启用后生成视觉校验循环(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,

View file

@ -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,

View file

@ -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,

View file

@ -198,6 +198,7 @@ mod tests {
provider: None,
design_md: None,
concurrency: 1,
continuation_context: None,
append_context: None,
validation_enabled: true,

View file

@ -78,6 +78,7 @@ pub fn build_seed_command(prompt: &str) -> Result<EditorCommand, SeedBuildError>
// 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,

View file

@ -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()