feat(editor): selection targets the chat turn and shows in the panel

An active canvas selection now biases intent routing to modify (the
selection IS the target — only an explicit new-whole-screen request
or a plain chat question escapes it), restoring select-then-ask
editing. The panel paints a selected-count chip above the input with
a clear affordance, so the armed state is visible. Chat-question CJK
keywords keep questions about a selection conversational, and the
footer hover math reserves the chip row like paint does.
This commit is contained in:
Fini 2026-07-04 12:30:26 +08:00
parent e891552970
commit a3540ac2e6
12 changed files with 463 additions and 18 deletions

View file

@ -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),

View file

@ -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<Rect> {
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<Rect> {
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;

View file

@ -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 个"));
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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>,
) -> 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"
);
}

View file

@ -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);

View file

@ -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();

View file

@ -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<String>,
) -> 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;

View file

@ -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<dyn Iterator<Item = ChatDelta> + 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>) -> 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
);
}

View file

@ -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"
);

View file

@ -190,6 +190,7 @@ pub fn stream_standard_turn<W: Write>(
let classified = crate::chat_intent::classify_intent_for_standard_route(
classify_provider.as_ref(),
&snapshot,
&req.ai.user,
model.clone(),
);