fix(agent): stop landing pages from scaffolding as sidebar dashboards

`plan_is_sidebar_dashboard` accepted a sidebar signal from ANY subtask,
so a landing page whose plan happens to carry a nav/menu section was
built on the two-column dashboard scaffold — a sidebar rail down the
left of a page that should be a full-width hero stack.

Require the signal to come from the FIRST subtask (a real sidebar is the
leading section, not an incidental one), and let a plan's landing-page
anatomy veto an ambiguous signal via `plan_has_landing_anatomy`. An
explicit landing-page request now vetoes every dashboard signal, while
an explicit dashboard or admin-console request still wins ahead of the
structural check, so the unambiguous cases are decided by what the user
asked for rather than by section keywords.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
Fini 2026-07-31 21:06:11 +08:00
parent 72fd294514
commit d709f2c572
4 changed files with 195 additions and 18 deletions

View file

@ -20,8 +20,19 @@ use crate::plan::{OrchestratorPlan, Subtask};
///
/// Port of TS `isSidebarSubtask` (`orchestrator.ts:175-180`).
pub(crate) fn is_sidebar_subtask(st: &Subtask) -> bool {
let text = subtask_text(st);
has_sidebar_keyword(&text) && !has_topbar_keyword(&text)
let identity = subtask_identity_text(st);
if has_strong_sidebar_keyword(&identity) {
return true;
}
if has_topbar_keyword(&identity) {
return false;
}
if has_sidebar_keyword(&identity) {
return true;
}
let elements = st.elements.as_deref().unwrap_or_default().to_lowercase();
(identity.contains("left") || identity.contains("side")) && has_sidebar_keyword(&elements)
}
/// A STRONG sidebar signal — "sidebar" / "side bar" / "side nav" / "rail" — is
@ -30,15 +41,7 @@ pub(crate) fn is_sidebar_subtask(st: &Subtask) -> bool {
/// app-shell scaffold without requiring a dashboard-content gate.
pub(crate) fn is_strong_sidebar_subtask(st: &Subtask) -> bool {
let t = subtask_text(st);
(t.contains("sidebar")
|| t.contains("side bar")
|| t.contains("side nav")
|| t.contains("side-nav")
|| t.contains("left rail")
|| t.contains("left nav")
|| t.contains("nav rail")
|| t.contains("navigation rail"))
&& !has_topbar_keyword(&t)
has_strong_sidebar_keyword(&t)
}
/// Returns `true` when the prompt + plan subtasks suggest a dashboard-like
@ -47,18 +50,53 @@ pub(crate) fn is_strong_sidebar_subtask(st: &Subtask) -> bool {
/// Matches `/(dashboard|admin|analytics|fintech|workspace|data)/` over
/// `prompt` concatenated with every subtask's `id + label + elements`.
///
/// Explicit landing-page intent vetoes every dashboard signal. Otherwise an
/// explicit dashboard or admin-console request wins before the plan's
/// structural landing-page anatomy is considered.
///
/// Port of TS `isDashboardLikePrompt` (`orchestrator.ts:192-197`).
pub(crate) fn is_dashboard_like_prompt(prompt: &str, plan: &OrchestratorPlan) -> bool {
let prompt = prompt.to_lowercase();
if has_explicit_landing_page_intent(&prompt) {
return false;
}
if has_explicit_dashboard_intent(&prompt) {
return true;
}
if plan_has_landing_anatomy(plan) {
return false;
}
let subtask_text: String = plan
.subtasks
.iter()
.map(subtask_text)
.collect::<Vec<_>>()
.join("\n");
let text = format!("{}\n{}", prompt, subtask_text).to_lowercase();
let text = format!("{prompt}\n{subtask_text}");
has_dashboard_keyword(&text)
}
pub(crate) fn plan_has_landing_anatomy(plan: &OrchestratorPlan) -> bool {
let identities: Vec<String> = plan.subtasks.iter().map(subtask_identity_text).collect();
let has_hero = identities.iter().any(|text| text.contains("hero"));
let has_supporting_story = identities.iter().any(|text| {
text.contains("feature")
|| text.contains("capabilit")
|| text.contains("workflow")
|| text.contains("testimonial")
|| text.contains("customer proof")
|| text.contains("logo")
});
let has_conversion_end = identities.iter().any(|text| {
text.contains("pricing")
|| text.contains("faq")
|| text.contains("footer")
|| text.contains("final cta")
|| text.contains("call to action")
});
has_hero && has_supporting_story && has_conversion_end
}
/// Returns `true` when the subtask is a dashboard-grade CONTENT section — a
/// table, metric/KPI/stat block, or chart. Used (with a sidebar subtask) as the
/// structural gate for pre-building the two-column scaffold, so a landing page
@ -91,6 +129,21 @@ fn subtask_text(st: &Subtask) -> String {
.to_lowercase()
}
fn subtask_identity_text(st: &Subtask) -> String {
format!("{} {}", st.id, st.label).to_lowercase()
}
fn has_strong_sidebar_keyword(text: &str) -> bool {
text.contains("sidebar")
|| text.contains("side bar")
|| text.contains("side nav")
|| text.contains("side-nav")
|| text.contains("left rail")
|| text.contains("left nav")
|| text.contains("nav rail")
|| text.contains("navigation rail")
}
/// `/(sidebar|side bar|navigation|nav|menu)/`
fn has_sidebar_keyword(text: &str) -> bool {
text.contains("sidebar")
@ -102,7 +155,20 @@ fn has_sidebar_keyword(text: &str) -> bool {
/// `/(top bar|header)/`
fn has_topbar_keyword(text: &str) -> bool {
text.contains("top bar") || text.contains("header")
text.contains("top bar")
|| text.contains("header")
|| text.contains("top navigation")
|| text.contains("navigation bar")
|| text.contains("nav bar")
|| text.contains("navbar")
}
fn has_explicit_landing_page_intent(text: &str) -> bool {
text.contains("landing page") || text.contains("landing-page")
}
fn has_explicit_dashboard_intent(text: &str) -> bool {
text.contains("dashboard") || text.contains("admin console") || text.contains("admin-console")
}
/// `/(dashboard|admin|analytics|fintech|workspace|data)/`

View file

@ -63,10 +63,24 @@ fn sidebar_subtask_true_for_nav_in_elements() {
}
#[test]
fn sidebar_subtask_false_for_top_bar() {
// "Top Navigation Bar" does NOT contain "top bar" or "header",
// so it IS a sidebar — use a proper "Top Bar" example for the false case.
let st = subtask("top-bar", "Top Bar", None);
fn sidebar_subtask_false_for_navigation_bar() {
let st = subtask("nav", "Navigation Bar", None);
assert!(!is_sidebar_subtask(&st));
}
#[test]
fn sidebar_subtask_true_for_sidebar_navigation_bar() {
let st = subtask("sidebar", "Sidebar Navigation Bar", None);
assert!(is_sidebar_subtask(&st));
}
#[test]
fn sidebar_subtask_false_for_footer_navigation_links() {
let st = subtask(
"cta",
"Final CTA & Footer",
Some("conversion CTA, footer navigation links, legal links"),
);
assert!(!is_sidebar_subtask(&st));
}
@ -119,6 +133,78 @@ fn dashboard_like_false_for_landing_page() {
));
}
#[test]
fn explicit_landing_page_intent_vetoes_dashboard_intent() {
let plan = plan_with(
1440.0,
vec![subtask(
"analytics",
"Analytics Dashboard",
Some("KPI cards and revenue chart"),
)],
);
assert!(!is_dashboard_like_prompt(
"Design a landing-page for an analytics dashboard",
&plan
));
}
#[test]
fn explicit_dashboard_intent_wins_over_landing_anatomy() {
let plan = plan_with(
1440.0,
vec![
subtask("hero", "Hero Summary", Some("analytics KPIs")),
subtask("workflow", "Workflow", Some("operations activity")),
subtask("footer", "Footer", Some("workspace links")),
],
);
assert!(plan_has_landing_anatomy(&plan));
assert!(is_dashboard_like_prompt(
"Design an analytics dashboard for a growth team",
&plan
));
}
#[test]
fn explicit_admin_console_intent_wins_over_landing_anatomy() {
let plan = plan_with(
1440.0,
vec![
subtask("hero", "Hero Summary", Some("account status")),
subtask("workflow", "Workflow", Some("support queue")),
subtask("footer", "Footer", Some("admin links")),
],
);
assert!(plan_has_landing_anatomy(&plan));
assert!(is_dashboard_like_prompt(
"Design an admin-console for support operations",
&plan
));
}
#[test]
fn dashboard_like_false_for_landing_page_with_data_sections() {
let plan = plan_with(
1440.0,
vec![
subtask("nav", "Navigation Bar", Some("main navigation links")),
subtask("hero", "Hero Section", Some("headline and product visual")),
subtask(
"capabilities",
"Capability Stories",
Some("live node graph"),
),
subtask("proof", "Customer Proof", Some("three key metrics cards")),
subtask("faq", "FAQ", Some("data privacy guarantees")),
],
);
assert!(!is_dashboard_like_prompt(
"Design a responsive website for an AI workbench",
&plan
));
}
// ---- infer_dashboard_section_height ----------------------------------------
#[test]

View file

@ -557,10 +557,20 @@ pub(crate) const CONTENT_COLUMN_NAME: &str = "Main Content";
pub(crate) fn plan_is_sidebar_dashboard(plan: &OrchestratorPlan, is_mobile: bool) -> bool {
use crate::dashboard_columns::{
is_dashboard_content_subtask, is_sidebar_subtask, is_strong_sidebar_subtask,
plan_has_landing_anatomy,
};
if is_mobile || plan.root_frame.width < 900.0 {
return false;
}
let Some(first) = plan.subtasks.first() else {
return false;
};
if !is_sidebar_subtask(first) {
return false;
}
if !is_strong_sidebar_subtask(first) && plan_has_landing_anatomy(plan) {
return false;
}
let sidebars = plan
.subtasks
.iter()
@ -581,7 +591,7 @@ pub(crate) fn plan_is_sidebar_dashboard(plan: &OrchestratorPlan, is_mobile: bool
// a sidebar dashboard whose sections lack table/metric/chart keywords used
// to fall through to the single-root path and fill the sidebar full-width
// during streaming.
if plan.subtasks.iter().any(is_strong_sidebar_subtask) {
if is_strong_sidebar_subtask(first) {
return true;
}
// Only an AMBIGUOUS nav/menu signal → require >=2 data-content sections so a

View file

@ -253,6 +253,21 @@ fn plan_is_sidebar_dashboard_false_for_landing_nav_without_data() {
assert!(!plan_is_sidebar_dashboard(&p, false));
}
#[test]
fn plan_is_sidebar_dashboard_false_for_landing_nav_with_graph_and_metrics() {
let mut p = plan();
p.root_frame.width = 1440.0;
p.subtasks = vec![
st("nav", "Navigation"),
st("hero", "Hero Section"),
st("capabilities", "Capability Graph"),
st("proof", "Customer Metrics"),
st("pricing", "Pricing"),
st("cta", "Final CTA & Footer Navigation"),
];
assert!(!plan_is_sidebar_dashboard(&p, false));
}
#[test]
fn plan_is_sidebar_dashboard_false_when_narrow() {
let mut p = sidebar_dashboard_plan();