diff --git a/crates/op-orchestrator/src/concurrent_tests.rs b/crates/op-orchestrator/src/concurrent_tests.rs index 528493e42..89aa3284b 100644 --- a/crates/op-orchestrator/src/concurrent_tests.rs +++ b/crates/op-orchestrator/src/concurrent_tests.rs @@ -33,6 +33,7 @@ fn subtask_with_screen(id: &str, screen: Option<&str>) -> Subtask { parent_frame_id: None, elements: None, screen: screen.map(|s| s.to_string()), + generated_root_id: None, } } @@ -62,6 +63,7 @@ fn make_plan_with_subtasks(subtask_ids: &[&str]) -> crate::plan::OrchestratorPla parent_frame_id: Some("root".into()), elements: None, screen: None, + generated_root_id: None, }) .collect(), style_guide_name: None, diff --git a/crates/op-orchestrator/src/concurrent_tests_b2.rs b/crates/op-orchestrator/src/concurrent_tests_b2.rs index 33fd4b56a..6c81478e1 100644 --- a/crates/op-orchestrator/src/concurrent_tests_b2.rs +++ b/crates/op-orchestrator/src/concurrent_tests_b2.rs @@ -50,6 +50,7 @@ fn make_two_screen_plan() -> crate::plan::OrchestratorPlan { parent_frame_id: Some("root".into()), elements: None, screen: Some("login".into()), + generated_root_id: None, }, crate::plan::Subtask { id: "s1".into(), @@ -62,6 +63,7 @@ fn make_two_screen_plan() -> crate::plan::OrchestratorPlan { parent_frame_id: Some("root".into()), elements: None, screen: Some("home".into()), + generated_root_id: None, }, ], style_guide_name: None, @@ -83,6 +85,7 @@ fn make_three_screen_plan() -> crate::plan::OrchestratorPlan { parent_frame_id: Some("root".into()), elements: None, screen: Some(screen.into()), + generated_root_id: None, }; OrchestratorPlan { root_frame: RootFrameSpec { diff --git a/crates/op-orchestrator/src/dashboard_columns.rs b/crates/op-orchestrator/src/dashboard_columns.rs index 09feebe05..21100aef7 100644 --- a/crates/op-orchestrator/src/dashboard_columns.rs +++ b/crates/op-orchestrator/src/dashboard_columns.rs @@ -720,6 +720,16 @@ mod slots; #[allow(unused_imports)] // consumed by run.rs in a later task pub(crate) use slots::{assign_dashboard_main_parents, RowFrameEntry}; +// --------------------------------------------------------------------------- +// §4.7 Placeholder height + child reorder — sibling file (800-line cap) +// --------------------------------------------------------------------------- + +mod height_reorder; +#[allow(unused_imports)] // consumed by run.rs in a later task +pub(crate) use height_reorder::{ + get_dashboard_placeholder_height, reorder_dashboard_main_children, +}; + // --------------------------------------------------------------------------- // Tests — split into sibling files to stay under 800 lines // --------------------------------------------------------------------------- @@ -735,3 +745,7 @@ mod tests_b; #[cfg(test)] #[path = "dashboard_columns_tests_b2.rs"] mod tests_b2; + +#[cfg(test)] +#[path = "dashboard_columns_tests_b3.rs"] +mod tests_b3; diff --git a/crates/op-orchestrator/src/dashboard_columns/height_reorder.rs b/crates/op-orchestrator/src/dashboard_columns/height_reorder.rs new file mode 100644 index 000000000..5cc974e6f --- /dev/null +++ b/crates/op-orchestrator/src/dashboard_columns/height_reorder.rs @@ -0,0 +1,207 @@ +//! §4.7 Placeholder height + child reorder. +//! +//! Split from `dashboard_columns.rs` to keep both files under the 800-line +//! ceiling. Faithful port of `orchestrator.ts:486-547`. + +use super::{group_dashboard_main_rows, is_sidebar_subtask}; +use crate::plan::OrchestratorPlan; +use jian_ops_schema::node::PenNode; +use op_editor_core::{EditorCommand, EditorState, NodeId, PenNodeExt}; + +// --------------------------------------------------------------------------- +// §4.7a: `get_dashboard_placeholder_height` +// --------------------------------------------------------------------------- + +/// Estimates the dashboard main-column placeholder height from the first 2-3 +/// rows' tallest subtasks + the sidebar height. +/// +/// Port of TS `getDashboardPlaceholderHeight` (`orchestrator.ts:486-508`). +/// +/// Formula: +/// 1. `sidebarHeight = Σ max(0, st.region.height)` for sidebar subtasks. +/// 2. Group main rows via `group_dashboard_main_rows`. +/// 3. `visibleRows = rows[..min(rows.len(), if rows.len() >= 3 { 3 } else { 2 })]`. +/// 4. `foldHeight = Σ tallest-height-in-row + (visibleRows.len - 1) * rowGap`. +/// 5. `mainFoldHint = if !visibleRows.is_empty() { foldHeight } else { 560 }`. +/// 6. `sidebarHint = if sidebarHeight > 0 { min(sidebarHeight, 600) } else { 0 }`. +/// 7. Return `clamp(max(mainFoldHint, sidebarHint), 560, 680)`. +pub(crate) fn get_dashboard_placeholder_height(plan: &OrchestratorPlan) -> f64 { + let row_groups = group_dashboard_main_rows(plan); + let rows = &row_groups.rows; + let row_gap = row_groups.row_gap; + + // Sidebar height: sum of max(0, height) for sidebar subtasks. + let sidebar_height: f64 = plan + .subtasks + .iter() + .filter(|st| is_sidebar_subtask(st)) + .map(|st| f64::max(0.0, st.region.height)) + .sum(); + + // TS: `rows.slice(0, rows.length >= 3 ? 3 : 2)` + let take = if rows.len() >= 3 { 3 } else { 2 }; + let visible_rows: Vec<&Vec> = rows.iter().take(take).collect(); + + // foldHeight: sum per row of the max height among its subtasks + row gaps. + let fold_height: f64 = visible_rows + .iter() + .map(|row| { + // TS: `Math.max(0, ...row.map(st => typeof st.region.height === 'number' ? st.region.height : 0))` + row.iter() + .map(|&i| { + let h = plan.subtasks[i].region.height; + if h.is_finite() { + f64::max(0.0, h) + } else { + 0.0 + } + }) + .fold(0.0_f64, f64::max) + }) + .sum::() + + f64::max(0.0, visible_rows.len() as f64 - 1.0) * row_gap; + + // TS: `mainFoldHint = visibleRows.length > 0 ? foldHeight : 560` + let main_fold_hint = if visible_rows.is_empty() { + 560.0 + } else { + fold_height + }; + + // TS: `sidebarHint = sidebarHeight > 0 ? Math.min(sidebarHeight, 600) : 0` + let sidebar_hint = if sidebar_height > 0.0 { + f64::min(sidebar_height, 600.0) + } else { + 0.0 + }; + + // TS: `Math.max(560, Math.min(Math.max(mainFoldHint, sidebarHint), 680))` + f64::max(main_fold_hint, sidebar_hint).clamp(560.0, 680.0) +} + +// --------------------------------------------------------------------------- +// §4.7b: `reorder_dashboard_main_children` +// --------------------------------------------------------------------------- + +/// After sub-agent generation, re-sorts the main column's children to plan order. +/// +/// For each non-sidebar subtask (in plan order), resolves which child of `main_id` +/// contains it (slot or row frame), then emits a `MoveNode` command for that +/// ancestor. Duplicate ancestors are deduplicated. +/// +/// Port of TS `reorderDashboardMainChildren` (`orchestrator.ts:510-547`). +/// +/// # Algorithm +/// For each non-sidebar subtask, the ancestor under `main_id` is resolved as: +/// 1. If `subtask.parent_frame_id.is_some() && != main_id`: +/// a. If that frame is a direct child of `main_id` → use it (slot = direct). +/// b. Else if *its* parent is a direct child of `main_id` → use that parent (row). +/// 2. Else → fall back to `subtask.generated_root_id`. +/// +/// Each resolved ancestor id is pushed (once) to `desiredOrder`, then +/// `MoveNode { node_id: ancestor, target_parent: main_id }` is emitted for +/// every id in `desiredOrder` order — which causes `EditorState::apply` to +/// detach + re-append them in the desired sequence. +pub(crate) fn reorder_dashboard_main_children( + plan: &OrchestratorPlan, + main_id: &str, + state: &EditorState, +) -> Vec { + // Find the main node in the live document. + let main_children: Vec = { + let top = state.active_children(); + // The main frame may be a top-level child or nested (inside the root scaffold). + match find_children_of(top, main_id) { + Some(ch) => ch.iter().map(|n| n.id_str().to_string()).collect(), + None => return vec![], + } + }; + + // Build desiredOrder. + let mut desired_order: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + let push_once = + |id: &str, desired: &mut Vec, seen: &mut std::collections::HashSet| { + if !id.is_empty() && !seen.contains(id) { + seen.insert(id.to_string()); + desired.push(id.to_string()); + } + }; + + for subtask in &plan.subtasks { + if is_sidebar_subtask(subtask) { + continue; + } + + if let Some(pfid) = subtask.parent_frame_id.as_deref() { + if pfid != main_id { + // Is pfid a direct child of main? + if main_children.contains(&pfid.to_string()) { + push_once(pfid, &mut desired_order, &mut seen); + continue; + } + + // Is pfid's parent a direct child of main? (pfid is a slot inside a row) + let parent_of_pfid = find_parent_id_of(state.active_children(), pfid); + if let Some(row_id) = parent_of_pfid { + if main_children.contains(&row_id) { + push_once(&row_id, &mut desired_order, &mut seen); + continue; + } + } + } + } + + // Fallback: use generatedRootId. + if let Some(root_id) = subtask.generated_root_id.as_deref() { + push_once(root_id, &mut desired_order, &mut seen); + } + } + + // Emit MoveNode for each id in desiredOrder — applied in sequence, these + // detach + re-append each child, producing the desired order. + let main_node_id = NodeId::new(main_id.to_string()); + desired_order + .into_iter() + .map(|id| EditorCommand::MoveNode { + node_id: NodeId::new(id), + target_parent: main_node_id.clone(), + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Returns the children of the node with `target_id` anywhere in the forest, +/// or `None` when not found / not a container. +fn find_children_of<'a>(children: &'a [PenNode], target_id: &str) -> Option<&'a Vec> { + for child in children { + if child.id_str() == target_id { + return child.children(); + } + if let Some(grand) = child.children() { + if let Some(found) = find_children_of(grand, target_id) { + return Some(found); + } + } + } + None +} + +/// Returns the id of the direct parent of `target_id` in the forest, or `None`. +fn find_parent_id_of(children: &[PenNode], target_id: &str) -> Option { + for child in children { + if let Some(grand) = child.children() { + if grand.iter().any(|n| n.id_str() == target_id) { + return Some(child.id_str().to_string()); + } + if let Some(id) = find_parent_id_of(grand, target_id) { + return Some(id); + } + } + } + None +} diff --git a/crates/op-orchestrator/src/dashboard_columns_tests.rs b/crates/op-orchestrator/src/dashboard_columns_tests.rs index 4e73852be..0c97bba76 100644 --- a/crates/op-orchestrator/src/dashboard_columns_tests.rs +++ b/crates/op-orchestrator/src/dashboard_columns_tests.rs @@ -31,6 +31,7 @@ fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask { parent_frame_id: None, elements: elements.map(String::from), screen: None, + generated_root_id: None, } } diff --git a/crates/op-orchestrator/src/dashboard_columns_tests_b.rs b/crates/op-orchestrator/src/dashboard_columns_tests_b.rs index d01a792db..1c4ceef92 100644 --- a/crates/op-orchestrator/src/dashboard_columns_tests_b.rs +++ b/crates/op-orchestrator/src/dashboard_columns_tests_b.rs @@ -32,6 +32,7 @@ fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask { parent_frame_id: None, elements: elements.map(String::from), screen: None, + generated_root_id: None, } } diff --git a/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs b/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs index 1c951ed19..c287b5b1d 100644 --- a/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs +++ b/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs @@ -33,6 +33,7 @@ fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask { parent_frame_id: None, elements: elements.map(String::from), screen: None, + generated_root_id: None, } } diff --git a/crates/op-orchestrator/src/dashboard_columns_tests_b3.rs b/crates/op-orchestrator/src/dashboard_columns_tests_b3.rs new file mode 100644 index 000000000..b5c2bd7ab --- /dev/null +++ b/crates/op-orchestrator/src/dashboard_columns_tests_b3.rs @@ -0,0 +1,343 @@ +//! Task B3 tests for `get_dashboard_placeholder_height` + +//! `reorder_dashboard_main_children`. +//! Linked via `#[path]` in `dashboard_columns.rs`. + +use super::*; +use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask}; +use crate::test_support::VecDocSink; +use op_editor_core::{EditorCommand, NodeId, PenNodeExt}; +use serde_json::json; + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn root_spec(width: f64) -> RootFrameSpec { + RootFrameSpec { + id: "root".into(), + name: "Design".into(), + width, + height: 800.0, + layout: Some("vertical".into()), + gap: None, + padding: None, + fill: None, + } +} + +fn st(id: &str, label: &str, width: f64, height: f64) -> Subtask { + Subtask { + id: id.into(), + label: label.into(), + region: Region { width, height }, + id_prefix: id.into(), + parent_frame_id: None, + elements: None, + screen: None, + generated_root_id: None, + } +} + +fn sidebar_st(height: f64) -> Subtask { + Subtask { + id: "sidebar-nav".into(), + label: "Sidebar Navigation".into(), + region: Region { + width: 260.0, + height, + }, + id_prefix: "sidebar-nav".into(), + parent_frame_id: None, + elements: Some("nav links".into()), + screen: None, + generated_root_id: None, + } +} + +fn plan_with(width: f64, subtasks: Vec) -> OrchestratorPlan { + OrchestratorPlan { + root_frame: root_spec(width), + subtasks, + style_guide_name: None, + } +} + +// ── `get_dashboard_placeholder_height` tests ───────────────────────────────── + +/// Below-floor: no non-sidebar rows → mainFoldHint=560, sidebarHint=0 → 560. +#[test] +fn placeholder_height_empty_rows_returns_floor() { + // Only a sidebar subtask with height 0 → no non-sidebar rows produced. + let plan = plan_with(1200.0, vec![sidebar_st(0.0)]); + let h = get_dashboard_placeholder_height(&plan); + assert_eq!(h, 560.0, "empty rows → floor 560"); +} + +/// In-range: 1 row with topbar h=600, sidebar h=700 (capped to 600 → sidebarHint=600). +/// visibleRows = first 2 (rows.len()=1 < 3) = [topbar row]. +/// foldHeight = 600 (tallest in 1 row) + 0 gaps = 600. +/// mainFoldHint = 600; sidebarHint = min(700,600)=600. +/// max(600,600)=600 → clamp(560..680) = 600. +#[test] +fn placeholder_height_in_range() { + let sidebar = sidebar_st(700.0); + let topbar = st("topbar", "Top Bar", 940.0, 600.0); + let plan = plan_with(1200.0, vec![sidebar, topbar]); + let h = get_dashboard_placeholder_height(&plan); + assert!( + (560.0..=680.0).contains(&h), + "in-range plan should give h in [560,680]: got {h}" + ); + assert_eq!(h, 600.0, "specific formula check: expected 600"); +} + +/// Above-ceiling: 2 standalone rows each h=500. +/// foldHeight = 500 + 24 + 500 = 1024 → clamped to 680. +#[test] +fn placeholder_height_above_ceiling_clamped() { + let sidebar = sidebar_st(0.0); + // full_width = max(1200-260=940, 940) = 940; standalone if w >= 940*0.82=770.8 + let s1 = st("s1", "S1", 940.0, 500.0); + let s2 = st("s2", "S2", 940.0, 500.0); + let plan = plan_with(1200.0, vec![sidebar, s1, s2]); + let h = get_dashboard_placeholder_height(&plan); + assert_eq!(h, 680.0, "above-ceiling: expected 680 (clamped)"); +} + +/// When rows.len() >= 3, TS uses first 3. +/// 3 standalone rows h=300,200,100; rowGap=24. +/// foldHeight = 300+24+200+24+100 = 648 → clamp = 648. +#[test] +fn placeholder_height_three_rows_uses_first_three() { + let sidebar = sidebar_st(0.0); + let s1 = st("s1", "S1", 940.0, 300.0); + let s2 = st("s2", "S2", 940.0, 200.0); + let s3 = st("s3", "S3", 940.0, 100.0); + let plan = plan_with(1200.0, vec![sidebar, s1, s2, s3]); + let h = get_dashboard_placeholder_height(&plan); + assert_eq!(h, 648.0, "3 rows: expected 648"); +} + +/// When rows.len() < 3 (here 2 rows), TS uses first 2. +/// heights: 350, 250; rowGap=24; foldHeight = 350+24+250 = 624. +#[test] +fn placeholder_height_two_rows_uses_first_two() { + let sidebar = sidebar_st(0.0); + let s1 = st("s1", "S1", 940.0, 350.0); + let s2 = st("s2", "S2", 940.0, 250.0); + let plan = plan_with(1200.0, vec![sidebar, s1, s2]); + let h = get_dashboard_placeholder_height(&plan); + assert_eq!(h, 624.0, "2 rows: expected 624"); +} + +// ── `reorder_dashboard_main_children` tests ────────────────────────────────── + +/// Builds a PenNode frame from JSON for test use. +fn frame_val(id: &str, name: &str, children: serde_json::Value) -> serde_json::Value { + json!({ + "type": "frame", "id": id, "name": name, + "width": 100, "height": 100, + "children": children, + }) +} + +/// Finds the actual id of the first node named `name` in `state`'s active children +/// (recursively). +fn find_by_name(state: &op_editor_core::EditorState, name: &str) -> Option { + fn search(nodes: &[jian_ops_schema::node::PenNode], name: &str) -> Option { + for node in nodes { + if node.base().name.as_deref() == Some(name) { + return Some(node.id_str().to_string()); + } + if let Some(ch) = node.children() { + if let Some(id) = search(ch, name) { + return Some(id); + } + } + } + None + } + search(state.active_children(), name) +} + +/// Basic reorder: 3 direct-child slots in order [C, B, A]; plan order [A, B, C]. +/// Each subtask's parent_frame_id = its slot id (direct child of main). +/// Expected: 3 MoveNode commands in order slot-A, slot-B, slot-C. +#[test] +fn reorder_produces_move_node_commands_in_plan_order() { + let mut sink = VecDocSink::new(); + + // Insert main frame with children in [C, B, A] order. + let main_node: jian_ops_schema::node::PenNode = serde_json::from_value(json!({ + "type": "frame", "id": "root-main", "name": "Main", + "width": 940, "height": 100, + "children": [ + frame_val("slot-c", "SlotC", json!([])), + frame_val("slot-b", "SlotB", json!([])), + frame_val("slot-a", "SlotA", json!([])), + ], + })) + .unwrap(); + sink.state.apply(EditorCommand::InsertSubtree { + nodes: vec![main_node], + parent_id: NodeId::NONE, + }); + + let main_id = find_by_name(&sink.state, "Main").expect("Main not found"); + let slot_a_id = find_by_name(&sink.state, "SlotA").expect("SlotA not found"); + let slot_b_id = find_by_name(&sink.state, "SlotB").expect("SlotB not found"); + let slot_c_id = find_by_name(&sink.state, "SlotC").expect("SlotC not found"); + + // Plan: sidebar + subtask-a (pfid=slot-a) + subtask-b (pfid=slot-b) + subtask-c (pfid=slot-c). + let sidebar = sidebar_st(0.0); + let mut st_a = st("subtask-a", "Subtask A", 940.0, 200.0); + st_a.parent_frame_id = Some(slot_a_id.clone()); + let mut st_b = st("subtask-b", "Subtask B", 940.0, 200.0); + st_b.parent_frame_id = Some(slot_b_id.clone()); + let mut st_c = st("subtask-c", "Subtask C", 940.0, 200.0); + st_c.parent_frame_id = Some(slot_c_id.clone()); + let plan = plan_with(1200.0, vec![sidebar, st_a, st_b, st_c]); + + let cmds = reorder_dashboard_main_children(&plan, &main_id, &sink.state); + + // Extract moved node ids. + let moved_ids: Vec = cmds + .iter() + .filter_map(|cmd| match cmd { + EditorCommand::MoveNode { node_id, .. } => Some(node_id.as_str().to_string()), + _ => None, + }) + .collect(); + + // All MoveNode targets should be main_id. + for cmd in &cmds { + if let EditorCommand::MoveNode { target_parent, .. } = cmd { + assert_eq!( + target_parent.as_str(), + main_id, + "MoveNode target must be main_id" + ); + } + } + + assert_eq!(moved_ids.len(), 3, "expected 3 MoveNode commands"); + assert_eq!(moved_ids[0], slot_a_id, "first move: slot-a"); + assert_eq!(moved_ids[1], slot_b_id, "second move: slot-b"); + assert_eq!(moved_ids[2], slot_c_id, "third move: slot-c"); +} + +/// Slot-in-row: subtask's parentFrameId is a slot under a row under main. +/// Expected: the row id (not the slot id) is used in the MoveNode command. +#[test] +fn reorder_resolves_slot_to_row_ancestor() { + let mut sink = VecDocSink::new(); + + // main has: [row-frame (with slot-a, slot-b), standalone-slot] + let main_node: jian_ops_schema::node::PenNode = serde_json::from_value(json!({ + "type": "frame", "id": "root-main", "name": "Main", + "width": 940, "height": 100, + "children": [ + { + "type": "frame", "id": "row-2", "name": "Row2", + "width": "fill_container", "height": "fit_content", + "layout": "horizontal", + "children": [ + frame_val("slot-a", "SlotA", json!([])), + frame_val("slot-b", "SlotB", json!([])), + ], + }, + frame_val("standalone-slot", "StandaloneSlot", json!([])), + ], + })) + .unwrap(); + sink.state.apply(EditorCommand::InsertSubtree { + nodes: vec![main_node], + parent_id: NodeId::NONE, + }); + + let main_id = find_by_name(&sink.state, "Main").expect("Main not found"); + let row2_id = find_by_name(&sink.state, "Row2").expect("Row2 not found"); + let standalone_id = + find_by_name(&sink.state, "StandaloneSlot").expect("StandaloneSlot not found"); + let slot_a_id = find_by_name(&sink.state, "SlotA").expect("SlotA not found"); + + // Plan: sidebar + standalone (pfid=standalone-slot, direct child of main) + // + subtask-a (pfid=slot-a, child of row-2 which is child of main) + let sidebar = sidebar_st(0.0); + let mut standalone_subtask = st("standalone", "Standalone", 940.0, 96.0); + standalone_subtask.parent_frame_id = Some(standalone_id.clone()); + let mut subtask_a = st("subtask-a", "Subtask A", 460.0, 300.0); + subtask_a.parent_frame_id = Some(slot_a_id.clone()); + let plan = plan_with(1200.0, vec![sidebar, standalone_subtask, subtask_a]); + + let cmds = reorder_dashboard_main_children(&plan, &main_id, &sink.state); + let moved_ids: Vec = cmds + .iter() + .filter_map(|cmd| match cmd { + EditorCommand::MoveNode { node_id, .. } => Some(node_id.as_str().to_string()), + _ => None, + }) + .collect(); + + assert_eq!(moved_ids.len(), 2, "expected 2 MoveNode commands"); + assert_eq!( + moved_ids[0], standalone_id, + "first: standalone slot (direct child of main)" + ); + assert_eq!(moved_ids[1], row2_id, "second: row-2 (parent of slot-a)"); +} + +/// Sidebar subtasks are skipped. +#[test] +fn reorder_skips_sidebar_subtasks() { + let sink = VecDocSink::new(); + let plan = plan_with(1200.0, vec![sidebar_st(300.0)]); + let cmds = reorder_dashboard_main_children(&plan, "root-main", &sink.state); + assert!(cmds.is_empty(), "all-sidebar plan → no commands"); +} + +/// Falls back to `generated_root_id` when `parent_frame_id` is None. +#[test] +fn reorder_uses_generated_root_id_as_fallback() { + let mut sink = VecDocSink::new(); + + let main_node: jian_ops_schema::node::PenNode = serde_json::from_value(json!({ + "type": "frame", "id": "root-main", "name": "Main", + "width": 940, "height": 100, + "children": [ + frame_val("gen-b", "GenB", json!([])), + frame_val("gen-a", "GenA", json!([])), + ], + })) + .unwrap(); + sink.state.apply(EditorCommand::InsertSubtree { + nodes: vec![main_node], + parent_id: NodeId::NONE, + }); + + let main_id = find_by_name(&sink.state, "Main").expect("Main not found"); + let gen_a_id = find_by_name(&sink.state, "GenA").expect("GenA not found"); + let gen_b_id = find_by_name(&sink.state, "GenB").expect("GenB not found"); + + // subtask-a: no parent_frame_id → falls back to generated_root_id = gen_a. + // subtask-b: no parent_frame_id → falls back to generated_root_id = gen_b. + let sidebar = sidebar_st(0.0); + let mut st_a = st("subtask-a", "Subtask A", 940.0, 200.0); + st_a.parent_frame_id = None; + st_a.generated_root_id = Some(gen_a_id.clone()); + let mut st_b = st("subtask-b", "Subtask B", 940.0, 200.0); + st_b.parent_frame_id = None; + st_b.generated_root_id = Some(gen_b_id.clone()); + let plan = plan_with(1200.0, vec![sidebar, st_a, st_b]); + + let cmds = reorder_dashboard_main_children(&plan, &main_id, &sink.state); + let moved_ids: Vec = cmds + .iter() + .filter_map(|cmd| match cmd { + EditorCommand::MoveNode { node_id, .. } => Some(node_id.as_str().to_string()), + _ => None, + }) + .collect(); + + assert_eq!(moved_ids.len(), 2, "expected 2 MoveNode commands"); + assert_eq!(moved_ids[0], gen_a_id, "first: gen_a"); + assert_eq!(moved_ids[1], gen_b_id, "second: gen_b"); +} diff --git a/crates/op-orchestrator/src/plan.rs b/crates/op-orchestrator/src/plan.rs index 7ee507607..6646df674 100644 --- a/crates/op-orchestrator/src/plan.rs +++ b/crates/op-orchestrator/src/plan.rs @@ -71,6 +71,10 @@ pub struct Subtask { /// 对应的屏幕 / 页面名 —— port of TS `SubTask.screen`。 #[serde(default)] pub screen: Option, + /// sub-agent 运行后记录下的顶层节点 id —— port of TS + /// `SubTask.generatedRootId`。仪器由 run.rs 填写;规划阶段为 None。 + #[serde(skip)] + pub generated_root_id: Option, } /// 规划阶段的完整产物。字段对齐规划语料 `decomposition.md`。 @@ -172,6 +176,7 @@ pub fn build_fallback_plan(req: &DesignRequest) -> OrchestratorPlan { parent_frame_id: None, elements: None, screen: None, + generated_root_id: None, } }) .collect(); diff --git a/crates/op-orchestrator/src/plan_normalize.rs b/crates/op-orchestrator/src/plan_normalize.rs index a79e2731d..c7c7afb64 100644 --- a/crates/op-orchestrator/src/plan_normalize.rs +++ b/crates/op-orchestrator/src/plan_normalize.rs @@ -72,6 +72,7 @@ mod tests { parent_frame_id: None, elements: None, screen: None, + generated_root_id: None, } } diff --git a/crates/op-orchestrator/src/plan_repair.rs b/crates/op-orchestrator/src/plan_repair.rs index f7fd57bb1..f60d854ab 100644 --- a/crates/op-orchestrator/src/plan_repair.rs +++ b/crates/op-orchestrator/src/plan_repair.rs @@ -358,6 +358,7 @@ fn coerce_subtask( parent_frame_id: None, elements: None, screen: None, + generated_root_id: None, }); } @@ -408,6 +409,7 @@ fn coerce_subtask( parent_frame_id: None, elements, screen, + generated_root_id: None, }) } diff --git a/crates/op-orchestrator/src/prompt.rs b/crates/op-orchestrator/src/prompt.rs index da71548c9..e3efbee0d 100644 --- a/crates/op-orchestrator/src/prompt.rs +++ b/crates/op-orchestrator/src/prompt.rs @@ -309,6 +309,7 @@ mod tests { parent_frame_id: Some("root".into()), elements: None, screen: None, + generated_root_id: None, }; let cr = build_subagent_prompt(&st, &plan(), &req(), AbortFlag::new(), false, false); assert!(cr.user_prompt.contains("Hero")); @@ -329,6 +330,7 @@ mod tests { parent_frame_id: None, elements: None, screen: None, + generated_root_id: None, }; // minimal_skills=true: the system prompt should contain "schema" skill // content and "jsonl-format" skill content, but NOT layout/text-rules etc. @@ -359,6 +361,7 @@ mod tests { parent_frame_id: None, elements: None, screen: None, + generated_root_id: None, }; // req() uses model "claude" which is Full tier — no narrowing. // Use a basic-tier model to test narrowing. @@ -392,6 +395,7 @@ mod tests { parent_frame_id: None, elements: None, screen: None, + generated_root_id: None, }; // req() uses "claude" which maps to Full tier → reduced_complexity is no-op let full_cr = build_subagent_prompt(&st, &plan(), &req(), AbortFlag::new(), false, false); @@ -503,6 +507,7 @@ mod tests { parent_frame_id: None, elements: None, screen: None, + generated_root_id: None, } } diff --git a/crates/op-orchestrator/src/scaffold.rs b/crates/op-orchestrator/src/scaffold.rs index ff13f17bd..75e0c14fc 100644 --- a/crates/op-orchestrator/src/scaffold.rs +++ b/crates/op-orchestrator/src/scaffold.rs @@ -303,6 +303,7 @@ mod tests { parent_frame_id: None, elements: None, screen: screen.map(|s| s.to_string()), + generated_root_id: None, } } diff --git a/crates/op-orchestrator/src/subagent.rs b/crates/op-orchestrator/src/subagent.rs index 664c455b5..d4c9ba4ca 100644 --- a/crates/op-orchestrator/src/subagent.rs +++ b/crates/op-orchestrator/src/subagent.rs @@ -137,6 +137,7 @@ mod tests { parent_frame_id: None, elements: None, screen: None, + generated_root_id: None, } }