fix(ai): stop streaming chat turns

This commit is contained in:
Fini 2026-05-31 18:34:37 +08:00
parent 2278e8377d
commit bd9459feba
9 changed files with 192 additions and 5 deletions

View file

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

View 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);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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