fix(ai): stop streaming chat turns
This commit is contained in:
parent
2278e8377d
commit
bd9459feba
|
|
@ -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;
|
||||
|
|
|
|||
28
crates/op-editor-core/tests/chat_stop.rs
Normal file
28
crates/op-editor-core/tests/chat_stop.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<ChatSession>,
|
||||
current_design: &mut Option<DesignSession>,
|
||||
) -> 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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue