diff --git a/crates/op-editor-ui/src/widgets/ai_chat_hit.rs b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs index b2f31dbb2..7e3486bbb 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_hit.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs @@ -63,6 +63,9 @@ pub enum AIChatHit { /// Click on a staged-attachment chip — payload is the index /// into `chat.pending_attachments` to drop. RemoveAttachment(usize), + /// Click on the selected-count chip's clear target — host clears + /// the current canvas selection. + ClearSelection, /// Click on a message's thinking-block header — host toggles /// `ChatMessage::thinking_collapsed` for that message index. ToggleThinking(usize), diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs index e55d9a50b..4de098639 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -41,6 +41,11 @@ pub(crate) const RESIZE_GUTTER: f32 = 4.0; pub(crate) const RESIZE_CORNER: f32 = 12.0; pub(crate) const INPUT_AREA_HEIGHT: f32 = 56.0; pub(crate) const INPUT_TOOLBAR_HEIGHT: f32 = 40.0; +pub(crate) const SELECTION_CHIP_ROW_HEIGHT: f32 = 28.0; +const SELECTION_CHIP_HEIGHT: f32 = 20.0; +const SELECTION_CHIP_PAD_X: f32 = 10.0; +const SELECTION_CHIP_CLEAR_W: f32 = 18.0; +const SELECTION_CHIP_FONT: f32 = 11.0; #[cfg(test)] const INPUT_BASE_HEIGHT: f32 = INPUT_AREA_HEIGHT + INPUT_TOOLBAR_HEIGHT; @@ -210,6 +215,48 @@ impl<'a> AIChatPlaceholder<'a> { } } + /// Height of the selected-count chip row — `0` when no canvas node is selected. + pub(crate) fn selection_chip_row_h(&self) -> f32 { + if self.selected_count == 0 { + 0.0 + } else { + SELECTION_CHIP_ROW_HEIGHT + } + } + + fn selection_chip_label(&self) -> String { + op_i18n::translate(self.locale, "common.selected") + .replace("{{count}}", &self.selected_count.to_string()) + } + + pub(crate) fn selection_chip_rect(&self, input_rect: Rect) -> Option { + if self.selected_count == 0 { + return None; + } + let label = self.selection_chip_label(); + let label_w = footer_label_width(&label, SELECTION_CHIP_FONT); + let chip_w = (SELECTION_CHIP_PAD_X + label_w + 6.0 + SELECTION_CHIP_CLEAR_W) + .min(input_rect.size.x) + .max(56.0); + Some(Rect { + origin: Point2D::new( + input_rect.origin.x, + input_rect.origin.y + (SELECTION_CHIP_ROW_HEIGHT - SELECTION_CHIP_HEIGHT) / 2.0, + ), + size: Point2D::new(chip_w, SELECTION_CHIP_HEIGHT), + }) + } + + pub(crate) fn selection_chip_clear_rect(&self, input_rect: Rect) -> Option { + self.selection_chip_rect(input_rect).map(|chip| Rect { + origin: Point2D::new( + chip.origin.x + chip.size.x - SELECTION_CHIP_CLEAR_W, + chip.origin.y, + ), + size: Point2D::new(SELECTION_CHIP_CLEAR_W, chip.size.y), + }) + } + /// Total input-block height, including the attachment row when /// attachments are staged. #[cfg(test)] @@ -232,7 +279,10 @@ impl<'a> AIChatPlaceholder<'a> { } pub(crate) fn input_height_for_width(&self, panel_w: f32) -> f32 { - self.input_area_height_for_width(panel_w) + INPUT_TOOLBAR_HEIGHT + self.attachment_row_h() + self.selection_chip_row_h() + + self.input_area_height_for_width(panel_w) + + INPUT_TOOLBAR_HEIGHT + + self.attachment_row_h() } pub(crate) fn input_area_height_for_rect(&self, rect: Rect) -> f32 { @@ -283,8 +333,10 @@ impl<'a> AIChatPlaceholder<'a> { &self.state.available_models, self.model_picker_input.text(), ); - let toolbar_top = - input_rect.origin.y + self.input_area_height_for_rect(rect) + self.attachment_row_h(); + let toolbar_top = input_rect.origin.y + + self.selection_chip_row_h() + + self.input_area_height_for_rect(rect) + + self.attachment_row_h(); let bottom = toolbar_top - 4.0; Rect { origin: Point2D::new(rect.origin.x + PAD, bottom - height), @@ -348,6 +400,41 @@ impl<'a> AIChatPlaceholder<'a> { } } +fn paint_selection_chip( + cx: &mut PaintCx<'_>, + theme: &Theme, + widget: &AIChatPlaceholder<'_>, + input_rect: Rect, +) { + let Some(chip) = widget.selection_chip_rect(input_rect) else { + return; + }; + let label = widget.selection_chip_label(); + cx.backend.fill_round_rect(chip, 6.0, theme.muted); + let label_layout = TextLayout::single_run( + &label, + "system-ui", + SELECTION_CHIP_FONT, + (theme.muted_foreground).to_jian(), + Point2D::new(0.0, 0.0), + ); + let baseline_y = chip.origin.y + chip.size.y / 2.0 + SELECTION_CHIP_FONT * 0.35; + cx.backend.draw_text( + &label_layout, + Point2D::new(chip.origin.x + SELECTION_CHIP_PAD_X, baseline_y), + ); + let clear_layout = TextLayout::single_run( + "×", + "system-ui", + SELECTION_CHIP_FONT, + (theme.muted_foreground).to_jian(), + Point2D::new(0.0, 0.0), + ); + let clear_x = chip.origin.x + chip.size.x - SELECTION_CHIP_CLEAR_W / 2.0 - 3.0; + cx.backend + .draw_text(&clear_layout, Point2D::new(clear_x, baseline_y)); +} + #[derive(Debug, Clone, Copy)] pub(crate) struct FooterLayout { /// Model picker pill — left anchor of the toolbar. @@ -601,8 +688,11 @@ impl<'a> Widget for AIChatPlaceholder<'a> { self.state.checklist_scroll.offset, ); } + let input_block_rect = self.input_rect(rect); + paint_selection_chip(cx, &self.theme, self, input_block_rect); + let selection_h = self.selection_chip_row_h(); let input_rect = Rect { - origin: Point2D::new(rect.origin.x + PAD, sep_y + 1.0), + origin: Point2D::new(rect.origin.x + PAD, sep_y + 1.0 + selection_h), size: Point2D::new( rect.size.x - PAD * 2.0, self.input_area_height_for_rect(rect), @@ -692,3 +782,7 @@ mod tests_paint; #[cfg(test)] #[path = "ai_chat_panel/tests_transcript.rs"] mod tests_transcript; + +#[cfg(test)] +#[path = "ai_chat_panel/tests_selected_chip.rs"] +mod tests_selected_chip; diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_selected_chip.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_selected_chip.rs new file mode 100644 index 000000000..9e134c228 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_selected_chip.rs @@ -0,0 +1,111 @@ +//! Selected-count chip layout, paint, and hit-test tests. + +use super::*; +use crate::widgets::ai_chat_hit::AIChatHit; + +#[derive(Default)] +struct SelectedChipPaintBackend { + texts: Vec<(String, f32, jian_core::scene::Color, Point2D)>, +} + +impl crate::RenderBackend for SelectedChipPaintBackend { + fn begin_frame(&mut self) {} + fn end_frame(&mut self) {} + fn fill_rect(&mut self, _: Rect, _: crate::Color) {} + fn stroke_rect(&mut self, _: Rect, _: crate::Color, _: f32) {} + fn draw_text(&mut self, layout: &crate::TextLayout, origin: Point2D) { + if let Some(run) = layout.runs().first() { + self.texts + .push((run.content.clone(), run.font_size, run.color, origin)); + } + } + fn clip_rect(&mut self, _: Rect) {} + fn stroke_line(&mut self, _: Point2D, _: Point2D, _: crate::Color, _: f32) {} + fn fill_round_rect(&mut self, _: Rect, _: f32, _: crate::Color) {} + fn stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {} + fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: crate::Color, _: f32) {} + fn save(&mut self) {} + fn restore(&mut self) {} + fn translate(&mut self, _: Point2D) {} + fn resize(&mut self, _: u32, _: u32) {} + fn dpi_scale(&self) -> f32 { + 1.0 + } +} + +fn panel_with_selection(count: usize) -> (EditorState, Rect) { + let mut state = EditorState::new(); + state.selection.set = (0..count) + .map(|idx| op_editor_core::NodeId::new(format!("n{idx}"))) + .collect(); + let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); + (state, rect) +} + +fn paint_panel(panel: &AIChatPlaceholder<'_>, rect: Rect) -> SelectedChipPaintBackend { + let mut backend = SelectedChipPaintBackend::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + panel.paint(&mut cx, rect); + backend +} + +#[test] +fn selected_count_zero_has_no_chip_row() { + let (state, rect) = panel_with_selection(0); + let panel = AIChatPlaceholder::from_editor(&state); + + assert_eq!(panel.selection_chip_row_h(), 0.0); + assert_eq!(panel.input_height(), INPUT_BASE_HEIGHT); + + let backend = paint_panel(&panel, rect); + assert!(!backend + .texts + .iter() + .any(|(text, _, _, _)| { text.contains("selected") || text.contains("已选择") })); +} + +#[test] +fn selected_count_three_paints_chip_text() { + let (state, rect) = panel_with_selection(3); + let panel = AIChatPlaceholder::from_editor(&state); + + assert_eq!(panel.selection_chip_row_h(), SELECTION_CHIP_ROW_HEIGHT); + assert_eq!( + panel.input_height(), + INPUT_BASE_HEIGHT + SELECTION_CHIP_ROW_HEIGHT + ); + + let backend = paint_panel(&panel, rect); + assert!(backend + .texts + .iter() + .any(|(text, size, _, _)| text.contains('3') && (*size - 11.0).abs() < 1e-4)); +} + +#[test] +fn selected_count_chip_clear_hit_returns_clear_selection() { + let (state, rect) = panel_with_selection(3); + let panel = AIChatPlaceholder::from_editor(&state); + let chip = panel.selection_chip_rect(panel.input_rect(rect)).unwrap(); + let point = Point2D::new( + chip.origin.x + chip.size.x - 9.0, + chip.origin.y + chip.size.y / 2.0, + ); + + assert_eq!(panel.hit_test(rect, point), Some(AIChatHit::ClearSelection)); +} + +#[test] +fn selected_count_chip_uses_zh_cn_template() { + let (mut state, rect) = panel_with_selection(3); + state.editor_ui.locale = op_editor_core::Locale::ZhCn; + let panel = AIChatPlaceholder::from_editor(&state); + + let backend = paint_panel(&panel, rect); + assert!(backend + .texts + .iter() + .any(|(text, _, _, _)| text == "已选择 3 个")); +} diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs index 64798335e..998558b69 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs @@ -128,7 +128,10 @@ impl<'a> AIChatPlaceholder<'a> { if self.parallel_agents_picker_open { let footer = self.footer_layout(rect, input_rect, { let attach_h = self.attachment_row_h(); - input_rect.origin.y + self.input_area_height_for_rect(rect) + attach_h + input_rect.origin.y + + self.selection_chip_row_h() + + self.input_area_height_for_rect(rect) + + attach_h }); if let Some(picker) = self.parallel_agents_picker_rect(rect, &footer) { if picker.contains(point) { @@ -151,9 +154,20 @@ impl<'a> AIChatPlaceholder<'a> { return Some(AIChatHit::ToggleParallelAgentsPicker); } if (input_rect).contains(point) { - let attach_top = input_rect.origin.y + input_area_h; + let selection_h = self.selection_chip_row_h(); + let text_top = input_rect.origin.y + selection_h; + let attach_top = text_top + input_area_h; let attach_h = self.attachment_row_h(); let toolbar_top = attach_top + attach_h; + if selection_h > 0.0 && point.y < text_top { + if self + .selection_chip_clear_rect(input_rect) + .is_some_and(|clear| clear.contains(point)) + { + return Some(AIChatHit::ClearSelection); + } + return Some(AIChatHit::FocusInput); + } if point.y < attach_top { if self.is_streaming() { return Some(AIChatHit::Inside); @@ -162,7 +176,7 @@ impl<'a> AIChatPlaceholder<'a> { return Some(AIChatHit::FocusInput); } let text_area = Rect { - origin: input_rect.origin, + origin: Point2D::new(input_rect.origin.x, text_top), size: Point2D::new(input_rect.size.x, input_area_h), }; let offset = crate::widgets::ai_chat_input_text::input_text_offset_at( @@ -428,7 +442,13 @@ impl<'a> AIChatPlaceholder<'a> { return None; } let attach_h = self.attachment_row_h(); - let toolbar_top = input_rect.origin.y + self.input_area_height_for_rect(rect) + attach_h; + // The chip row sits at the top of the input block — hover math must + // reserve it like paint does, or every footer band shifts up by the + // row height whenever a selection is active. + let toolbar_top = input_rect.origin.y + + self.selection_chip_row_h() + + self.input_area_height_for_rect(rect) + + attach_h; if point.y < toolbar_top { return None; } diff --git a/crates/op-host-desktop/src/chat_session_launch.rs b/crates/op-host-desktop/src/chat_session_launch.rs index 66a3713df..8d7c83e95 100644 --- a/crates/op-host-desktop/src/chat_session_launch.rs +++ b/crates/op-host-desktop/src/chat_session_launch.rs @@ -237,8 +237,14 @@ fn should_launch_direct_modify(state: &EditorState, user_text: &str) -> bool { // A whole-screen draw request ("继续画一下 search 页面") must reach the // design pipeline's new-frame route, never get hijacked into editing the // existing frame in place — even when it also trips the modify classifier. - !op_host_services::chat_intent::requests_new_whole_screen(user_text) - && op_host_services::chat_intent::looks_like_modify_request(user_text) + if op_host_services::chat_intent::requests_new_whole_screen(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; + (keyword_intent == op_host_services::chat_intent::DesignIntent::Modify + || selected_target_instruction) && op_host_services::chat_intent::build_modify_plan(state, user_text).is_some() } @@ -756,3 +762,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "chat_session_launch_selection_tests.rs"] +mod selection_tests; diff --git a/crates/op-host-desktop/src/chat_session_launch_selection_tests.rs b/crates/op-host-desktop/src/chat_session_launch_selection_tests.rs new file mode 100644 index 000000000..a9dc65050 --- /dev/null +++ b/crates/op-host-desktop/src/chat_session_launch_selection_tests.rs @@ -0,0 +1,71 @@ +//! Selection-biased direct-modify launch tests for builtin / ACP routing. + +use super::*; +use op_editor_core::pen_node_ext::PenNodeExt; + +fn frame( + id: &str, + name: &str, + children: Vec, +) -> jian_ops_schema::node::PenNode { + let mut node: jian_ops_schema::node::PenNode = serde_json::from_value(serde_json::json!({ + "type": "frame", + "id": id, + "name": name, + "width": 390, + "height": 120, + "children": [] + })) + .expect("frame fixture"); + if let Some(kids) = node.children_mut() { + *kids = children; + } + node +} + +fn state_with_selected_card() -> EditorState { + let mut state = EditorState::new(); + state.active_children_mut().clear(); + state.active_children_mut().push(frame( + "screen", + "Food App Home", + vec![frame("popular-card", "Bella Napoli Pizzeria", Vec::new())], + )); + state.set_single_selection(op_editor_core::NodeId::new("popular-card")); + state +} + +#[test] +fn selection_with_keywordless_instruction_launches_direct_modify() { + let state = state_with_selected_card(); + + assert!( + should_launch_direct_modify(&state, "给它加一个边框"), + "selected existing design + keyword-less edit wording should update in place" + ); +} + +#[test] +fn selection_does_not_hijack_whole_new_screen_or_chat() { + let state = state_with_selected_card(); + + assert!( + !should_launch_direct_modify(&state, "重新画一个首页"), + "whole-screen draw request must keep the new-design route" + ); + assert!( + !should_launch_direct_modify(&state, "这是什么字体"), + "plain chat questions must not become direct modify requests" + ); +} + +#[test] +fn no_selection_modify_keyword_behavior_is_unchanged() { + let mut state = state_with_selected_card(); + state.clear_selection(); + + assert!( + should_launch_direct_modify(&state, "修改成饺子"), + "explicit modify wording should still launch direct modify without a selection" + ); +} diff --git a/crates/op-host-desktop/src/main_tests.rs b/crates/op-host-desktop/src/main_tests.rs index 66b16552d..c187fa838 100644 --- a/crates/op-host-desktop/src/main_tests.rs +++ b/crates/op-host-desktop/src/main_tests.rs @@ -58,6 +58,35 @@ fn variable_row_input_keeps_resume_time_redraws_active() { assert_eq!(app.host.next_animation_deadline_ms(), Some(740)); } +#[test] +fn selected_count_chip_clear_click_clears_canvas_selection() { + let mut app = DesktopApp::new(None); + app.host.editor_state_mut().selection.set = vec![ + op_editor_core::NodeId::new("n1"), + op_editor_core::NodeId::new("n2"), + ]; + app.host.editor_state_mut().chat.panel_position = Some((100.0, 100.0)); + let chat = &app.host.editor_state().chat; + let chat_rect = op_editor_ui::Rect::xywh(100.0, 100.0, chat.panel_width, chat.panel_height); + let panel = op_editor_ui::widgets::AIChatPlaceholder::from_editor(app.host.editor_state()); + let input = panel.input_rect(chat_rect); + let clear_point = (0..160) + .flat_map(|dx| (0..28).map(move |dy| (dx, dy))) + .map(|(dx, dy)| { + op_editor_ui::Point2D::new(input.origin.x + dx as f32, input.origin.y + dy as f32) + }) + .find(|point| { + panel.hit_test(chat_rect, *point) + == Some(op_editor_ui::widgets::AIChatHit::ClearSelection) + }) + .expect("clear-selection hit point"); + + assert!(app + .host + .apply_click(clear_point.x, clear_point.y, 1200.0, 800.0)); + assert!(app.host.editor_state().selection.set.is_empty()); +} + #[test] fn fresh_app_fits_blank_frame_like_ts_canvas_init() { let app = DesktopApp::new(None); diff --git a/crates/op-host-native/src/widget_host/click.rs b/crates/op-host-native/src/widget_host/click.rs index ed8ed1499..5c2f3d702 100644 --- a/crates/op-host-native/src/widget_host/click.rs +++ b/crates/op-host-native/src/widget_host/click.rs @@ -301,6 +301,11 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + AIChatHit::ClearSelection => { + self.editor_state.clear_selection(); + self.mark_dirty(); + return true; + } AIChatHit::ToggleThinking(idx) => { self.editor_state.chat.toggle_message_thinking(idx); self.mark_dirty(); diff --git a/crates/op-host-services/src/chat_intent.rs b/crates/op-host-services/src/chat_intent.rs index a899ed603..b7000d0e8 100644 --- a/crates/op-host-services/src/chat_intent.rs +++ b/crates/op-host-services/src/chat_intent.rs @@ -103,6 +103,7 @@ const MODIFY_CJK: &[&str] = &[ const CHAT_KEYWORDS: &[&str] = &[ "what is", "how do", "explain", "tell me", "help", "why", "can you", "question", "describe", ]; +const CHAT_CJK: &[&str] = &["是什么", "什么", "怎么", "为什么", "解释", "说明", "帮助"]; fn is_word_char(c: char) -> bool { // JS `\w` — ASCII alphanumeric plus underscore. @@ -140,7 +141,8 @@ fn matches_any_word_phrase(text_lower: &str, phrases: &[&str]) -> bool { /// TS `classifyByKeywords` — verbatim rule order. pub fn classify_by_keywords(text: &str) -> DesignIntent { let lower = text.to_lowercase(); - let chat = matches_any_word_phrase(&lower, CHAT_KEYWORDS); + let chat = + matches_any_word_phrase(&lower, CHAT_KEYWORDS) || CHAT_CJK.iter().any(|k| text.contains(k)); let modify = matches_any_word_phrase(&lower, MODIFY_KEYWORDS) || MODIFY_CJK.iter().any(|k| text.contains(k)); if chat && !modify { @@ -162,6 +164,7 @@ pub fn looks_like_modify_request(text: &str) -> bool { /// prompts; keep those on the modify path before asking the model. pub fn classify_intent_for_standard_route( provider: &dyn ChatProvider, + state: &EditorState, text: &str, model: Option, ) -> DesignIntent { @@ -173,7 +176,13 @@ pub fn classify_intent_for_standard_route( if requests_new_whole_screen(text) { return DesignIntent::New; } - if looks_like_modify_request(text) { + let keyword_intent = classify_by_keywords(text); + 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; + if keyword_intent == DesignIntent::Modify || selected_target_instruction { return DesignIntent::Modify; } if is_named_follow_on_screen(text) { @@ -839,6 +848,7 @@ pub fn run_cli_turn( ) { let classified = classify_intent_for_standard_route( plan.classify_provider.as_ref(), + &plan.initial_state, &plan.user_text, plan.model.clone(), ); @@ -982,3 +992,7 @@ pub fn run_modify_turn( #[cfg(test)] #[path = "chat_intent_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "chat_intent_selection_tests.rs"] +mod selection_tests; diff --git a/crates/op-host-services/src/chat_intent_selection_tests.rs b/crates/op-host-services/src/chat_intent_selection_tests.rs new file mode 100644 index 000000000..bd1fd66f8 --- /dev/null +++ b/crates/op-host-services/src/chat_intent_selection_tests.rs @@ -0,0 +1,84 @@ +//! Selection-biased standard-route intent tests. + +use op_ai::chat_provider::{ChatDelta, ChatProvider, ChatRequest, StopReason}; +use op_editor_core::EditorState; +use std::time::Duration; + +use super::*; + +struct Scripted; + +impl ChatProvider for Scripted { + fn provider_label(&self) -> &str { + "scripted" + } + + fn send(&self, _request: ChatRequest) -> Box + Send> { + Box::new( + vec![ + ChatDelta::TextDelta("DESIGN_NEW".to_string()), + ChatDelta::Done { + stop_reason: StopReason::EndTurn, + }, + ] + .into_iter() + .inspect(|_| std::thread::sleep(Duration::ZERO)), + ) + } +} + +fn frame(id: &str, name: &str, children: Vec) -> PenNode { + let mut node: PenNode = serde_json::from_value(serde_json::json!({ + "type": "frame", + "id": id, + "name": name, + "x": 0.0, + "y": 0.0, + "width": 390.0, + "height": 800.0, + "children": [], + })) + .expect("valid frame json"); + if let Some(kids) = node.children_mut() { + *kids = children; + } + node +} + +fn state_with_selected_card() -> EditorState { + let mut state = EditorState::new(); + state.active_children_mut().clear(); + state.active_children_mut().push(frame( + "screen", + "Home", + vec![frame("card", "Selected Card", Vec::new())], + )); + state.set_single_selection(op_editor_core::NodeId::new("card")); + state +} + +#[test] +fn selection_bias_routes_keywordless_instruction_to_modify() { + let provider = Scripted; + let state = state_with_selected_card(); + + assert_eq!( + classify_intent_for_standard_route(&provider, &state, "给它加一个边框", None), + DesignIntent::Modify + ); +} + +#[test] +fn selection_bias_does_not_hijack_whole_new_screen_or_chat() { + let provider = Scripted; + let state = state_with_selected_card(); + + assert_eq!( + classify_intent_for_standard_route(&provider, &state, "重新画一个首页", None), + DesignIntent::New + ); + assert_eq!( + classify_intent_for_standard_route(&provider, &state, "这是什么字体", None), + DesignIntent::Chat + ); +} diff --git a/crates/op-host-services/src/chat_intent_tests.rs b/crates/op-host-services/src/chat_intent_tests.rs index de5040afc..1b9138784 100644 --- a/crates/op-host-services/src/chat_intent_tests.rs +++ b/crates/op-host-services/src/chat_intent_tests.rs @@ -351,14 +351,15 @@ fn whole_screen_draw_overrides_modify_in_standard_route() { // search 页面" has 修改 + 画 + 页面) must route to New, not Modify — else // it edits the existing frame instead of opening a new one. let provider = Scripted::text("DESIGN_MODIFY"); + let state = state_with_page(); assert_eq!( - classify_intent_for_standard_route(&provider, "修改后重新画一个search 页面", None), + classify_intent_for_standard_route(&provider, &state, "修改后重新画一个search 页面", None), DesignIntent::New, "a whole-screen draw must win over the modify classifier" ); // But a genuine edit of the current screen still routes to Modify. assert_eq!( - classify_intent_for_standard_route(&provider, "把这个页面改成深色", None), + classify_intent_for_standard_route(&provider, &state, "把这个页面改成深色", None), DesignIntent::Modify, "editing the current screen stays on the modify route" ); @@ -370,18 +371,19 @@ fn english_page_edits_stay_on_modify_route() { // for a new-screen draw — it needs a creation verb (draw/create/design/…), // which "change"/"resize"/"make" are not. let provider = Scripted::text("CHAT"); + let state = state_with_page(); assert_eq!( - classify_intent_for_standard_route(&provider, "change the home page layout", None), + classify_intent_for_standard_route(&provider, &state, "change the home page layout", None), DesignIntent::Modify, "editing an existing page is a modify, not a new design" ); assert_eq!( - classify_intent_for_standard_route(&provider, "resize the screen header", None), + classify_intent_for_standard_route(&provider, &state, "resize the screen header", None), DesignIntent::Modify, ); // A genuine English page DRAW still routes to New. assert_eq!( - classify_intent_for_standard_route(&provider, "draw a checkout page", None), + classify_intent_for_standard_route(&provider, &state, "draw a checkout page", None), DesignIntent::New, "a creation verb + page noun is a new screen" ); @@ -418,8 +420,9 @@ fn whole_screen_draw_requests_llm_design_md_extraction() { #[test] fn named_follow_on_page_forces_new_route_before_llm_classifier() { let provider = Scripted::text("CHAT"); + let state = state_with_page(); assert_eq!( - classify_intent_for_standard_route(&provider, "继续画出发现页", None), + classify_intent_for_standard_route(&provider, &state, "继续画出发现页", None), DesignIntent::New, "named app pages must not be classified as plain chat or append" ); diff --git a/crates/op-host-services/src/web_chat_standard.rs b/crates/op-host-services/src/web_chat_standard.rs index fda89e5da..4a3995abc 100644 --- a/crates/op-host-services/src/web_chat_standard.rs +++ b/crates/op-host-services/src/web_chat_standard.rs @@ -190,6 +190,7 @@ pub fn stream_standard_turn( let classified = crate::chat_intent::classify_intent_for_standard_route( classify_provider.as_ref(), + &snapshot, &req.ai.user, model.clone(), );