diff --git a/crates/op-editor-core/src/chat.rs b/crates/op-editor-core/src/chat.rs index 125eb6ca6..6ffc6f898 100644 --- a/crates/op-editor-core/src/chat.rs +++ b/crates/op-editor-core/src/chat.rs @@ -304,6 +304,10 @@ pub struct ChatState { /// or design worker that could otherwise keep appending into the /// fresh empty transcript. pub pending_new_chat: bool, + /// Raised when the user clicks the streaming turn's Stop + /// affordance. Unlike New Chat, the transcript stays visible; the + /// desktop event loop only drops the in-flight worker. + pub pending_stop_chat: bool, /// Full model catalog discovered from every *installed* CLI, /// before the connected-providers filter. The desktop host fills /// this from `model_discovery`; [`rebuild_available_models`] then @@ -359,6 +363,7 @@ impl Default for ChatState { caret_anchor_ms: 0, pending_send: None, pending_new_chat: false, + pending_stop_chat: false, discovered_models: Vec::new(), available_models: Vec::new(), selected_model: 0, @@ -470,12 +475,33 @@ impl ChatState { true } + /// Stop the currently streaming turn while keeping the visible + /// transcript. Returns true when either a queued send or streaming + /// bubble was actually cancelled. + pub fn stop_streaming(&mut self) -> bool { + let had_pending = self.pending_send.take().is_some(); + let mut had_streaming = false; + for msg in &mut self.messages { + if msg.streaming { + had_streaming = true; + msg.streaming = false; + } + } + if had_pending || had_streaming { + self.pending_stop_chat = true; + true + } else { + false + } + } + /// Start a fresh chat transcript and ask the host to abort any /// in-flight worker tied to the previous conversation. pub fn new_chat(&mut self) { self.messages.clear(); self.input.clear(); self.pending_send = None; + self.pending_stop_chat = false; self.pending_attachments.clear(); self.pending_attachment_pick = false; self.pending_new_chat = true; diff --git a/crates/op-editor-core/tests/chat_stop.rs b/crates/op-editor-core/tests/chat_stop.rs new file mode 100644 index 000000000..6c257c312 --- /dev/null +++ b/crates/op-editor-core/tests/chat_stop.rs @@ -0,0 +1,28 @@ +use op_editor_core::{ChatMessage, ChatState}; + +#[test] +fn stop_streaming_marks_current_turn_done_and_queues_host_abort() { + let mut chat = ChatState { + pending_send: Some("queued prompt".into()), + ..Default::default() + }; + chat.messages.push(ChatMessage::user("queued prompt")); + chat.messages.push(ChatMessage::assistant_streaming()); + + assert!(chat.stop_streaming()); + + assert!(chat.pending_send.is_none()); + assert!(chat.pending_stop_chat); + assert!( + chat.messages.iter().all(|message| !message.streaming), + "stop must freeze the transcript so stale worker deltas cannot keep animating" + ); +} + +#[test] +fn stop_streaming_is_inert_when_no_turn_is_active() { + let mut chat = ChatState::default(); + + assert!(!chat.stop_streaming()); + assert!(!chat.pending_stop_chat); +} 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 91c288180..1a099ff9e 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_hit.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs @@ -5,6 +5,9 @@ pub enum AIChatHit { FocusInput, /// Click landed on the send affordance. Send, + /// Click landed on the stop affordance shown during a streaming + /// turn. + Stop, /// Click landed on an example card; payload is the example's /// title (host fills the input with this). Example(String), 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 ab5052236..8e101dcb1 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -166,6 +166,10 @@ impl<'a> AIChatPlaceholder<'a> { [Icon::Maximize, Icon::Minimize][self.state.maximized as usize] } + fn is_streaming(&self) -> bool { + self.state.messages.iter().any(|message| message.streaming) + } + /// Bounds of the transcript body region. fn body_rect(&self, rect: Rect) -> Rect { let body_top = rect.origin.y + HEADER_HEIGHT; @@ -315,7 +319,11 @@ impl<'a> AIChatPlaceholder<'a> { return Some(AIChatHit::AddAttachment); } if point.x >= send_x { - return Some(AIChatHit::Send); + return Some(if self.is_streaming() { + AIChatHit::Stop + } else { + AIChatHit::Send + }); } } return Some(AIChatHit::FocusInput); @@ -717,15 +725,26 @@ impl<'a> Widget for AIChatPlaceholder<'a> { // both (TS parity: an attachment-only message is valid). let send_active = !self.state.input.trim().is_empty() || !self.state.pending_attachments.is_empty(); - let (send_bg, icon_color) = if send_active { - (self.theme.primary, self.theme.primary_foreground) + let streaming = self.is_streaming(); + let (send_bg, icon_color, send_icon) = if streaming { + ( + self.theme.destructive, + self.theme.primary_foreground, + Icon::Square, + ) + } else if send_active { + ( + self.theme.primary, + self.theme.primary_foreground, + Icon::Send, + ) } else { - (self.theme.muted, self.theme.muted_foreground) + (self.theme.muted, self.theme.muted_foreground, Icon::Send) }; cx.backend.fill_round_rect(send_rect, 6.0, send_bg); draw_icon( cx.backend, - Icon::Send, + send_icon, Point2D::new(send_rect.origin.x + 6.0, send_rect.origin.y + 6.0), 12.0, icon_color, diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs index 6ed24b625..1e5f9e58b 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs @@ -75,6 +75,20 @@ fn hit_test_resolves_send_at_right() { assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::Send)); } +#[test] +fn hit_test_resolves_stop_at_right_while_streaming() { + let mut s = EditorState::new(); + s.chat + .messages + .push(op_editor_core::ChatMessage::assistant_streaming()); + let panel = AIChatPlaceholder::from_editor(&s); + let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); + let send_x = AI_CHAT_WIDTH - PAD - 20.0; + let p = Point2D::new(send_x, toolbar_center_y()); + + assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::Stop)); +} + #[test] fn hit_test_resolves_bottom_toolbar_actions() { let s = EditorState::new(); diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index cd1e48856..e51056912 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -393,6 +393,13 @@ impl ApplicationHandler for DesktopApp { ) { self.redraw_dirty = true; } + if chat_session::drain_stop_request( + &mut self.host, + &mut self.current_chat, + &mut self.current_design, + ) { + self.redraw_dirty = true; + } // Pump in-flight AI chat deltas into this frame. if chat_session::pump(&mut self.host, &mut self.current_chat) { self.redraw_dirty = true; @@ -687,6 +694,13 @@ impl ApplicationHandler for DesktopApp { ) { self.request_redraw(true); } + if chat_session::drain_stop_request( + &mut self.host, + &mut self.current_chat, + &mut self.current_design, + ) { + self.request_redraw(true); + } // A click on the attach button raises // `pending_attachment_pick` — open the file picker. if chat_attachment::drain_attachment_pick(&mut self.host) { diff --git a/crates/op-host-desktop/src/chat_session.rs b/crates/op-host-desktop/src/chat_session.rs index 2cb4e5453..e17ee2b69 100644 --- a/crates/op-host-desktop/src/chat_session.rs +++ b/crates/op-host-desktop/src/chat_session.rs @@ -279,6 +279,23 @@ pub fn drain_new_chat_request( true } +/// Drain a Stop request raised by the widget layer. The transcript +/// has already had its streaming flags cleared; this only drops the +/// in-flight workers so stale deltas cannot append after cancellation. +pub fn drain_stop_request( + host: &mut WidgetHostNative, + current_chat: &mut Option, + current_design: &mut Option, +) -> bool { + if !std::mem::take(&mut host.editor_state_mut().chat.pending_stop_chat) { + return false; + } + *current_chat = None; + *current_design = None; + host.mark_editor_state_dirty(); + true +} + fn clear_fresh_starter_frame_for_design(state: &mut EditorState) -> bool { if state.doc != EditorState::starter().doc { return false; @@ -554,6 +571,39 @@ mod tests { assert!(!msg.streaming); } + #[test] + fn drain_stop_request_drops_session_without_clearing_transcript() { + let provider = Box::new(EchoProvider { + script: vec![ChatDelta::TextDelta("late".into())], + }); + let mut current = Some(ChatSession::start( + provider, + ChatRequest { + user_message: "x".into(), + max_output_tokens: 64, + ..Default::default() + }, + )); + let mut current_design = None; + let mut host = WidgetHostNative::new(); + host.editor_state_mut() + .chat + .messages + .push(ChatMessage::assistant_streaming()); + + assert!(host.editor_state_mut().chat.stop_streaming()); + assert!(drain_stop_request( + &mut host, + &mut current, + &mut current_design + )); + + assert!(current.is_none()); + assert!(!host.editor_state().chat.pending_stop_chat); + assert_eq!(host.editor_state().chat.messages.len(), 1); + assert!(!host.editor_state().chat.messages[0].streaming); + } + #[test] fn selected_builtin_model_routes_to_builtin_provider() { let mut host = WidgetHostNative::new(); diff --git a/crates/op-host-native/src/widget_host/click.rs b/crates/op-host-native/src/widget_host/click.rs index 1ea6d6be4..97a69c573 100644 --- a/crates/op-host-native/src/widget_host/click.rs +++ b/crates/op-host-native/src/widget_host/click.rs @@ -145,6 +145,11 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + AIChatHit::Stop => { + self.editor_state.chat.stop_streaming(); + self.mark_dirty(); + return true; + } AIChatHit::Example(text) => { self.editor_state.chat.input = text; self.editor_state.chat.focused = true; diff --git a/crates/op-host-native/src/widget_host/input_tests.rs b/crates/op-host-native/src/widget_host/input_tests.rs index 8460bf080..e65bfefd8 100644 --- a/crates/op-host-native/src/widget_host/input_tests.rs +++ b/crates/op-host-native/src/widget_host/input_tests.rs @@ -76,6 +76,34 @@ fn ai_chat_new_chat_click_clears_transcript_and_queues_abort() { assert!(host.editor_state().chat.pending_new_chat); } +#[test] +fn ai_chat_stop_click_keeps_transcript_and_queues_abort() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut() + .chat + .messages + .push(op_editor_core::ChatMessage::user("make a dashboard")); + host.editor_state_mut() + .chat + .messages + .push(op_editor_core::ChatMessage::assistant_streaming()); + let rect = host + .ai_chat_rect(1200.0, 800.0) + .expect("chat panel visible"); + let x = rect.origin.x + rect.size.x - 16.0 - 20.0; + let y = rect.origin.y + toolbar_center_y_for_test(); + + assert!(host.apply_click(x, y, 1200.0, 800.0)); + + assert_eq!(host.editor_state().chat.messages.len(), 2); + assert!(!host.editor_state().chat.messages[1].streaming); + assert!(host.editor_state().chat.pending_stop_chat); +} + +fn toolbar_center_y_for_test() -> f32 { + op_editor_ui::widgets::AI_CHAT_HEIGHT - 19.0 +} + #[test] fn escape_closes_one_overlay_per_press_in_priority_order() { // Codex CONCERN-2 regression: Escape used to clear all