feat(orchestrator): dashboard_columns — placeholder height + child reorder

Ports TS orchestrator.ts:486-547 to Rust (Task B3 of S3b-3).

- Add `generated_root_id: Option<String>` field (#[serde(skip)]) to
  `Subtask` so run.rs can record the top-level node id post-generation.
- Implement `get_dashboard_placeholder_height` — estimates dashboard
  main-column height from first 2-3 rows' tallest subtasks + sidebar,
  clamped to [560, 680].
- Implement `reorder_dashboard_main_children` — emits sequential
  `EditorCommand::MoveNode` commands to re-sort the main column's
  children back to plan order after sub-agent generation.
- Both functions live in the new split module
  `dashboard_columns/height_reorder.rs` (207 lines).
- 9 new tests in `dashboard_columns_tests_b3.rs`; suite grows from
  298 → 307 passing.
This commit is contained in:
Fini 2026-05-23 05:46:01 +08:00
parent 8ab8db7b35
commit a3c457ec2c
14 changed files with 587 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -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<usize>> = 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>()
+ 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<EditorCommand> {
// Find the main node in the live document.
let main_children: Vec<String> = {
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<String> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let push_once =
|id: &str, desired: &mut Vec<String>, seen: &mut std::collections::HashSet<String>| {
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<PenNode>> {
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<String> {
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
}

View file

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

View file

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

View file

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

View file

@ -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<Subtask>) -> 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<String> {
fn search(nodes: &[jian_ops_schema::node::PenNode], name: &str) -> Option<String> {
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<String> = 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<String> = 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<String> = 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");
}

View file

@ -71,6 +71,10 @@ pub struct Subtask {
/// 对应的屏幕 / 页面名 —— port of TS `SubTask.screen`。
#[serde(default)]
pub screen: Option<String>,
/// sub-agent 运行后记录下的顶层节点 id —— port of TS
/// `SubTask.generatedRootId`。仪器由 run.rs 填写;规划阶段为 None。
#[serde(skip)]
pub generated_root_id: Option<String>,
}
/// 规划阶段的完整产物。字段对齐规划语料 `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();

View file

@ -72,6 +72,7 @@ mod tests {
parent_frame_id: None,
elements: None,
screen: None,
generated_root_id: None,
}
}

View file

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

View file

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

View file

@ -303,6 +303,7 @@ mod tests {
parent_frame_id: None,
elements: None,
screen: screen.map(|s| s.to_string()),
generated_root_id: None,
}
}

View file

@ -137,6 +137,7 @@ mod tests {
parent_frame_id: None,
elements: None,
screen: None,
generated_root_id: None,
}
}