fix(agent): inherit artboards for sibling screen continuations
This commit is contained in:
parent
0b79902570
commit
bf93a459b1
|
|
@ -423,6 +423,7 @@ impl EditorCommand {
|
|||
C::BatchInsert { .. }
|
||||
| C::InsertSubtree { .. }
|
||||
| C::InsertAuthoredSubtree { .. }
|
||||
| C::InsertAuthoredSubtreePreservingRoots { .. }
|
||||
| C::RefineDesign { .. }
|
||||
| C::Batch { .. }
|
||||
| C::ReplaceAllMatchingProperties { .. }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ fn batchable(cmd: &EditorCommand) -> bool {
|
|||
| C::BatchInsert { .. }
|
||||
| C::InsertSubtree { .. }
|
||||
| C::InsertAuthoredSubtree { .. }
|
||||
| C::InsertAuthoredSubtreePreservingRoots { .. }
|
||||
| C::RefineDesign { .. }
|
||||
| C::SetVariableScalar { .. }
|
||||
| C::CreateVariable { .. }
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
126
crates/op-orchestrator/src/plan_continuation_contract.rs
Normal file
126
crates/op-orchestrator/src/plan_continuation_contract.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
86
crates/op-orchestrator/src/plan_normalize_nav.rs
Normal file
86
crates/op-orchestrator/src/plan_normalize_nav.rs
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ fn req() -> DesignRequest {
|
|||
provider: None,
|
||||
design_md: None,
|
||||
concurrency: 1,
|
||||
continuation_context: None,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ fn req() -> DesignRequest {
|
|||
provider: None,
|
||||
design_md: None,
|
||||
concurrency: 1,
|
||||
continuation_context: None,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ mod tests {
|
|||
provider: None,
|
||||
design_md: None,
|
||||
concurrency: 1,
|
||||
continuation_context: None,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue