From ed50b1b5b8de2b9af2aa803beac42789be3cdfbb Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 5 Jul 2026 16:52:02 +0800 Subject: [PATCH] fix(ai): never route a pristine-starter canvas into direct-modify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../src/chat_session_launch.rs | 10 ++++++ .../chat_session_launch_selection_tests.rs | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/crates/op-host-desktop/src/chat_session_launch.rs b/crates/op-host-desktop/src/chat_session_launch.rs index 65ab899dc..10c60117b 100644 --- a/crates/op-host-desktop/src/chat_session_launch.rs +++ b/crates/op-host-desktop/src/chat_session_launch.rs @@ -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. 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 index a9dc65050..881b9a474 100644 --- a/crates/op-host-desktop/src/chat_session_launch_selection_tests.rs +++ b/crates/op-host-desktop/src/chat_session_launch_selection_tests.rs @@ -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();