diff --git a/crates/op-orchestrator/src/dashboard_columns.rs b/crates/op-orchestrator/src/dashboard_columns.rs index 5176a1ccb..fee801ac0 100644 --- a/crates/op-orchestrator/src/dashboard_columns.rs +++ b/crates/op-orchestrator/src/dashboard_columns.rs @@ -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::>() .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 = 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)/` diff --git a/crates/op-orchestrator/src/dashboard_columns_tests.rs b/crates/op-orchestrator/src/dashboard_columns_tests.rs index 7c5641327..dae411f16 100644 --- a/crates/op-orchestrator/src/dashboard_columns_tests.rs +++ b/crates/op-orchestrator/src/dashboard_columns_tests.rs @@ -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] diff --git a/crates/op-orchestrator/src/scaffold.rs b/crates/op-orchestrator/src/scaffold.rs index 634197fce..ffc98281a 100644 --- a/crates/op-orchestrator/src/scaffold.rs +++ b/crates/op-orchestrator/src/scaffold.rs @@ -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 diff --git a/crates/op-orchestrator/src/scaffold_tests.rs b/crates/op-orchestrator/src/scaffold_tests.rs index 65303e9af..d807e1bcc 100644 --- a/crates/op-orchestrator/src/scaffold_tests.rs +++ b/crates/op-orchestrator/src/scaffold_tests.rs @@ -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();