fix(ai): never route a pristine-starter canvas into direct-modify

A fresh from-scratch canvas holds only the blank starter frame, and that
frame ships selected. A whole-new-design request phrased as a bare noun
phrase (no page/screen noun, no create verb — e.g. "Luxury webapp for
managing barbershop clients") slips past both new-screen exemption gates,
and the selected starter makes selected_target_instruction true, so the
request was launched as a modify turn on a near-empty canvas — the model
then emits an unparseable fragment and the user sees "Could not parse
design nodes". Guard should_launch_direct_modify: a canvas that is only
the blank starter frame has nothing real to modify, so any request on it
is NEW (desktop parity of web_chat_standard's page_children_empty => New).
This commit is contained in:
Fini 2026-07-05 16:52:02 +08:00
parent 768ea37d7b
commit ed50b1b5b8
2 changed files with 43 additions and 0 deletions

View file

@ -234,6 +234,16 @@ pub fn launch_if_pending(
}
fn should_launch_direct_modify(state: &EditorState, user_text: &str) -> bool {
// A pristine "from-scratch" canvas holds only the blank starter frame:
// there is nothing real to modify, so ANY design request on it is a NEW
// design, never a modify — even when the starter frame is selected and the
// prompt is a bare noun phrase the new-screen gates don't recognize
// (measured: "Luxury webapp for managing barbershop clients" on a fresh
// canvas fell into run_modify_turn → glm flat-nodes → empty `"`). This is
// the desktop parity of web_chat_standard's `page_children_empty => New`.
if active_page_is_blank_starter_frame(state) {
return false;
}
// 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.

View file

@ -35,6 +35,39 @@ fn state_with_selected_card() -> EditorState {
state
}
fn state_with_selected_header() -> EditorState {
let mut state = EditorState::new();
state.active_children_mut().clear();
state.active_children_mut().push(frame(
"screen",
"Client Dashboard",
vec![frame("header", "Header", Vec::new())],
));
state.set_single_selection(op_editor_core::NodeId::new("header"));
state
}
#[test]
fn blank_starter_canvas_never_launches_modify() {
let state = EditorState::starter();
assert!(!state.selection.is_empty(), "starter frame is selected");
assert!(
!should_launch_direct_modify(&state, "Luxury webapp for managing barbershop clients"),
"fresh blank starter canvas has no real target to modify"
);
}
#[test]
fn real_content_modify_request_still_launches_modify() {
let state = state_with_selected_header();
assert!(
should_launch_direct_modify(&state, "change the header color to blue"),
"selected real content + clear edit request should still update in place"
);
}
#[test]
fn selection_with_keywordless_instruction_launches_direct_modify() {
let state = state_with_selected_card();