From 9de224916458afa773f2ac281e880943dc1ce74c Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 4 Jul 2026 16:26:13 +0800 Subject: [PATCH] fix(ai): stop a stray selection hijacking a new-design prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A section-heavy new-design prompt ('Design a … page. Include a search section …') trips is_section_add_request, so requests_new_whole_screen returns false; the selection-modify bias then routed the whole prompt into run_modify_turn, where M3's flat-JSONL output was renest-dropped to nothing ('Could not parse design nodes'). Add a section-add-blind creation-signal veto to both routing gates so a new-design request reaches the design pipeline regardless of an active selection. --- .../src/chat_session_launch.rs | 7 +++++ crates/op-host-services/src/chat_intent.rs | 26 +++++++++++++++++-- .../src/chat_intent_selection_tests.rs | 21 +++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/op-host-desktop/src/chat_session_launch.rs b/crates/op-host-desktop/src/chat_session_launch.rs index 2be5e439b..65ab899dc 100644 --- a/crates/op-host-desktop/src/chat_session_launch.rs +++ b/crates/op-host-desktop/src/chat_session_launch.rs @@ -240,6 +240,13 @@ fn should_launch_direct_modify(state: &EditorState, user_text: &str) -> bool { if op_host_services::chat_intent::requests_new_whole_screen(user_text) { return false; } + // Same intent, but section-add-blind: a full new-page spec that mentions + // "section" ("Include a search section…") still reads as new, so a stray + // selection can't drag it into modify (measured: a travel-app design with + // a node selected fell into run_modify_turn → M3 flat-JSONL → empty). + if op_host_services::chat_intent::has_new_screen_creation_signal(user_text) { + return false; + } let keyword_intent = op_host_services::chat_intent::classify_by_keywords(user_text); let selected_target_instruction = !state.selection.set.is_empty() && keyword_intent != op_host_services::chat_intent::DesignIntent::Chat; diff --git a/crates/op-host-services/src/chat_intent.rs b/crates/op-host-services/src/chat_intent.rs index b7000d0e8..7cf213d6e 100644 --- a/crates/op-host-services/src/chat_intent.rs +++ b/crates/op-host-services/src/chat_intent.rs @@ -180,8 +180,13 @@ pub fn classify_intent_for_standard_route( if !state.selection.set.is_empty() && keyword_intent == DesignIntent::Chat { return DesignIntent::Chat; } - let selected_target_instruction = - !state.selection.set.is_empty() && keyword_intent != DesignIntent::Chat; + // A selection biases toward modify — UNLESS the prompt is itself a + // whole-screen design request (a stray selection must not hijack "Design + // a … page" into an edit; `requests_new_whole_screen` misses these when + // they mention "section", so check the veto-free creation signal too). + let selected_target_instruction = !state.selection.set.is_empty() + && keyword_intent != DesignIntent::Chat + && !has_new_screen_creation_signal(text); if keyword_intent == DesignIntent::Modify || selected_target_instruction { return DesignIntent::Modify; } @@ -468,6 +473,23 @@ pub fn requests_new_whole_screen(prompt: &str) -> bool { english_requests_new_screen(&prompt.to_lowercase()) } +/// Core "this is a NEW screen/design" signal: a creation verb aimed at a +/// page/screen noun. Unlike [`requests_new_whole_screen`] it does NOT veto on +/// section-add phrasing — a full new-page spec that merely mentions the word +/// "section" ("Include a search section…", "Deals of the Week section") still +/// reads as new. Used to stop a stray canvas selection from hijacking a +/// whole-screen design prompt into the modify path (measured: a travel-app +/// design with a node selected routed to modify → M3 flat-JSONL → empty). +pub fn has_new_screen_creation_signal(prompt: &str) -> bool { + let cjk_page = ["页面", "页", "屏幕", "屏"] + .iter() + .any(|k| prompt.contains(k)); + if cjk_page && DRAW_VERB_CJK.iter().any(|v| prompt.contains(v)) { + return true; + } + english_requests_new_screen(&prompt.to_lowercase()) +} + /// English new-screen detection by the DETERMINER nearest a page/screen noun: /// an indefinite article ("a / an / another / new") means a NEW page is being /// made ("make a login page", "draw a checkout page"); a definite/demonstrative diff --git a/crates/op-host-services/src/chat_intent_selection_tests.rs b/crates/op-host-services/src/chat_intent_selection_tests.rs index bd1fd66f8..554bacada 100644 --- a/crates/op-host-services/src/chat_intent_selection_tests.rs +++ b/crates/op-host-services/src/chat_intent_selection_tests.rs @@ -82,3 +82,24 @@ fn selection_bias_does_not_hijack_whole_new_screen_or_chat() { DesignIntent::Chat ); } + +#[test] +fn selection_does_not_hijack_english_section_heavy_new_design() { + // Regression: "Design a … page" whose spec mentions "section" three times + // trips `is_section_add_request`, so `requests_new_whole_screen` is false; + // a stray selection then dragged the whole new-design prompt into modify + // (measured: M3 flat-JSONL → "Could not parse design nodes"). The + // creation-signal veto keeps it New. + let provider = Scripted; + let state = state_with_selected_card(); + let prompt = "Design a travel booking mobile app explore page. Include a search section with \"Where to?\" input, date picker chips, and guest count. \"Deals of the Week\" section with 2 featured deal cards. Recently viewed section with 2 compact cards. Bottom tab bar. Warm, inviting design with orange accents."; + assert!( + crate::chat_intent::has_new_screen_creation_signal(prompt), + "creation signal must fire on a design-a-page prompt" + ); + assert_ne!( + classify_intent_for_standard_route(&provider, &state, prompt, None), + DesignIntent::Modify, + "a section-heavy new-design prompt must not be hijacked to modify by a selection" + ); +}