fix(agent): require navigation on primary mobile screens

This commit is contained in:
Fini 2026-07-24 07:09:54 +08:00 committed by Kayshen-X
parent b4322f6d2b
commit 1ae53fa615
3 changed files with 300 additions and 25 deletions

View file

@ -0,0 +1,59 @@
//! Classify mobile app roots whose bottom tab bar is structural anatomy.
use crate::plan::OrchestratorPlan;
/// A multi-section mobile plan whose root frame is named like an app
/// home/main/feed screen — the shape whose bottom tab bar is mandatory.
pub(super) fn plan_is_app_home_screen(plan: &OrchestratorPlan) -> bool {
if plan.subtasks.len() < 3 {
return false;
}
let name = normalize_screen_name(&plan.root_frame.name);
let secondary_flow = [
"detail",
"details",
"form",
"login",
"log in",
"sign in",
"sign up",
"signup",
"register",
"checkout",
"onboarding",
"confirmation",
"success",
"wizard",
"buy now",
]
.iter()
.any(|phrase| screen_name_has_phrase(&name, phrase));
if secondary_flow {
return false;
}
// Preserve the legacy substring behavior for established primary-screen
// markers. Planner names commonly compact these into HomeScreen,
// Newsfeed, Discovery, or Browser.
["home", "feed", "discover", "browse", "dashboard"]
.iter()
.any(|marker| name.contains(marker))
|| ["main screen", "now screen"]
.iter()
.any(|phrase| screen_name_has_phrase(&name, phrase))
}
fn normalize_screen_name(name: &str) -> String {
name.to_lowercase()
.split(|ch: char| !ch.is_alphanumeric())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn screen_name_has_phrase(name: &str, phrase: &str) -> bool {
let padded_name = format!(" {name} ");
let padded_phrase = format!(" {phrase} ");
padded_name.contains(&padded_phrase)
}

View file

@ -10,12 +10,20 @@ use crate::dashboard_columns::{
use crate::plan::{OrchestratorPlan, Region, Subtask};
use crate::types::DesignRequest;
#[path = "plan_home_intent.rs"]
mod plan_home_intent;
use plan_home_intent::plan_is_app_home_screen;
// 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)]
#[path = "plan_normalize_screen_groups_tests.rs"]
mod tests_screen_groups;
#[cfg(test)]
#[path = "plan_normalize_nav_tests.rs"]
mod tests_nav;
/// 规范化产出的派生信息。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NormInfo {
@ -59,12 +67,7 @@ fn strip_status_bar_fragments(text: &str) -> Option<String> {
fn prompt_requests_bottom_nav(prompt: &str) -> bool {
let hay = prompt.to_lowercase();
let negated = hay.contains("no bottom nav")
|| hay.contains("without bottom nav")
|| hay.contains("without bottom navigation")
|| hay.contains("不要底部导航")
|| hay.contains("不需要底部导航");
if negated {
if prompt_forbids_bottom_nav(prompt) {
return false;
}
hay.contains("bottom nav")
@ -77,6 +80,15 @@ fn prompt_requests_bottom_nav(prompt: &str) -> bool {
|| 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!(
"{} {} {}",
@ -95,6 +107,10 @@ fn is_bottom_nav_subtask(st: &Subtask) -> bool {
}
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;
}
@ -129,25 +145,6 @@ fn ensure_requested_bottom_nav_subtask(plan: &mut OrchestratorPlan, req: &Design
});
}
/// A multi-section mobile plan whose root frame is named like an app
/// home/main/feed screen — the shape whose bottom tab bar is mandatory.
fn plan_is_app_home_screen(plan: &OrchestratorPlan) -> bool {
if plan.subtasks.len() < 3 {
return false;
}
let name = plan.root_frame.name.to_lowercase();
[
"home",
"main screen",
"feed",
"discover",
"browse",
"dashboard",
]
.iter()
.any(|k| name.contains(k))
}
/// 就地规范化 `plan`:
/// - 一次性判定 `is_mobile`(根 frame 宽度);
/// - 移动端剔除 plan 自带的状态栏 subtask(状态栏改由 scaffold 注入);

View file

@ -0,0 +1,219 @@
use super::*;
use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask};
fn request(prompt: &str) -> DesignRequest {
DesignRequest {
prompt: prompt.into(),
model: None,
provider: None,
design_md: None,
concurrency: 1,
append_context: None,
validation_enabled: true,
visual_ref_enabled: false,
}
}
fn section(id: &str, label: &str) -> Subtask {
Subtask {
id: id.into(),
label: label.into(),
region: Region {
width: 375.0,
height: 120.0,
},
id_prefix: String::new(),
parent_frame_id: None,
elements: None,
screen: None,
generated_root_id: None,
existing_section_labels: None,
retry_feedback: None,
}
}
fn mobile_plan(name: &str, labels: &[(&str, &str)]) -> OrchestratorPlan {
OrchestratorPlan {
root_frame: RootFrameSpec {
id: "root".into(),
name: name.into(),
width: 375.0,
height: 812.0,
layout: None,
gap: None,
padding: None,
fill: None,
},
subtasks: labels
.iter()
.map(|(id, label)| section(id, label))
.collect(),
style_guide_name: None,
}
}
fn has_bottom_nav(plan: &OrchestratorPlan) -> bool {
plan.subtasks.iter().any(is_bottom_nav_subtask)
}
#[test]
fn meteo_now_screen_gets_bottom_navigation_backstop() {
let mut plan = mobile_plan(
"Meteo - Now Screen",
&[
("hero", "Header & Main Temperature Hero"),
("telemetry", "Telemetry & Wind Grid"),
("hourly", "Hourly Forecast Strip"),
("forecast", "7-Day Forecast List"),
("sun-arc", "Sunrise & Sunset Arc"),
],
);
normalize(
&mut plan,
&request("Design the Now screen for the Meteo weather app"),
);
assert!(has_bottom_nav(&plan));
let nav = plan
.subtasks
.last()
.expect("navbar should be appended last");
assert_eq!(nav.id, "bottom-navigation");
assert_eq!(nav.parent_frame_id.as_deref(), Some("root"));
assert_eq!(nav.region.width, 375.0);
}
#[test]
fn detail_screen_does_not_get_implicit_bottom_navigation() {
let mut plan = mobile_plan(
"Meteo - Forecast Detail Screen",
&[
("header", "Forecast Header"),
("chart", "Precipitation Chart"),
("metrics", "Weather Metrics"),
("advisory", "Weather Advisory"),
],
);
normalize(&mut plan, &request("Design a forecast detail screen"));
assert!(!has_bottom_nav(&plan));
}
#[test]
fn form_flow_does_not_get_nav_even_when_name_mentions_main_screen() {
let mut plan = mobile_plan(
"Account Form - Main Screen",
&[
("header", "Account Header"),
("fields", "Profile Fields"),
("preferences", "Preferences"),
("actions", "Save Actions"),
],
);
normalize(&mut plan, &request("Design a mobile account form"));
assert!(!has_bottom_nav(&plan));
}
#[test]
fn screen_name_matching_uses_word_boundaries() {
let mut plan = mobile_plan(
"Performance Screen",
&[
("summary", "Summary"),
("chart", "Trend Chart"),
("metrics", "Metrics"),
],
);
normalize(&mut plan, &request("Design a performance screen"));
assert!(!has_bottom_nav(&plan), "form must not match performance");
}
#[test]
fn legacy_compound_primary_screen_names_keep_bottom_navigation() {
for name in [
"FoodHomeScreen",
"Community Newsfeed",
"Discovery Screen",
"In-App Browser",
] {
let mut plan = mobile_plan(
name,
&[
("header", "Header"),
("content", "Primary Content"),
("summary", "Summary"),
],
);
normalize(&mut plan, &request("Design a primary mobile app screen"));
assert!(
has_bottom_nav(&plan),
"legacy primary-screen marker should still match: {name}"
);
}
}
#[test]
fn buy_now_screen_does_not_get_implicit_bottom_navigation() {
let mut plan = mobile_plan(
"Buy Now Screen",
&[
("summary", "Order Summary"),
("payment", "Payment Method"),
("actions", "Purchase Actions"),
],
);
normalize(&mut plan, &request("Design a Buy Now purchase flow"));
assert!(!has_bottom_nav(&plan));
}
#[test]
fn explicit_no_nav_request_overrides_primary_screen_backstop() {
let mut plan = mobile_plan(
"Meteo - Now Screen",
&[
("hero", "Temperature Hero"),
("hourly", "Hourly Forecast"),
("forecast", "7-Day Forecast"),
],
);
normalize(
&mut plan,
&request("Design the Meteo Now screen without bottom navigation"),
);
assert!(!has_bottom_nav(&plan));
}
#[test]
fn explicit_no_nav_request_removes_planned_bottom_navigation() {
let mut plan = mobile_plan(
"Meteo - Now Screen",
&[
("hero", "Temperature Hero"),
("hourly", "Hourly Forecast"),
("forecast", "7-Day Forecast"),
("bottom-nav", "Bottom Navigation"),
],
);
normalize(
&mut plan,
&request("Design the Meteo Now screen with no bottom nav"),
);
assert!(
!has_bottom_nav(&plan),
"an explicit no-nav instruction must override the model plan"
);
}