diff --git a/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs b/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs index 75c89cf7f..116f98157 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs @@ -29,6 +29,18 @@ const BODY_FONT: f32 = 12.0; const LINE_H: f32 = 16.0; /// Inner padding inside a bubble / block box. const BUBBLE_PAD: f32 = 8.0; +/// Text shown in the TS-style empty streaming assistant pill. +const TYPING_LABEL: &str = "Thinking"; +/// Horizontal padding inside the empty streaming assistant pill. +const TYPING_PAD_X: f32 = 10.0; +/// Vertical padding inside the empty streaming assistant pill. +const TYPING_PAD_Y: f32 = 4.0; +/// Gap between the "Thinking" label and animated dots. +const TYPING_LABEL_DOT_GAP: f32 = 6.0; +/// Diameter of one animated typing dot. +const TYPING_DOT: f32 = 4.0; +/// Horizontal gap between animated typing dots. +const TYPING_DOT_GAP: f32 = 2.0; /// Height of a collapsible header row (thinking / tool-calls). const HEADER_H: f32 = 22.0; /// Vertical gap between two messages. @@ -117,6 +129,18 @@ fn unit_budget(inner_w: f32) -> u32 { (inner_w / CHAR_UNIT_PX).floor().max(1.0) as u32 } +fn text_unit_width(text: &str) -> f32 { + text.chars().map(char_display_units).sum::() as f32 * CHAR_UNIT_PX +} + +fn typing_dots_width() -> f32 { + 3.0 * TYPING_DOT + 2.0 * TYPING_DOT_GAP +} + +fn typing_pill_width() -> f32 { + 2.0 * TYPING_PAD_X + text_unit_width(TYPING_LABEL) + TYPING_LABEL_DOT_GAP + typing_dots_width() +} + fn progress_failed(label: &str) -> bool { let lower = label.to_ascii_lowercase(); lower.contains("failed") || lower.starts_with("error:") @@ -264,7 +288,12 @@ fn build_item( && thinking.is_none() && tools.is_none(); let bubble = if typing { - let r = Rect::xywh(x, y, bubble_w, LINE_H + 2.0 * BUBBLE_PAD); + let r = Rect::xywh( + x, + y, + typing_pill_width().min(body.size.x), + LINE_H + 2.0 * TYPING_PAD_Y, + ); y += r.size.y; Some(TextBubble { rect: r, @@ -622,21 +651,29 @@ fn paint_collapsible(cx: &mut PaintCx<'_>, theme: &Theme, block: &Collapsible) { } } -/// Paint the animated "assistant is typing" dots inside `rect`. -fn paint_typing_dots(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rect, now_ms: u64) { +/// Paint the animated "assistant is typing" dots after the label. +fn paint_typing_dots( + cx: &mut PaintCx<'_>, + theme: &Theme, + start_x: f32, + center_y: f32, + now_ms: u64, +) { let phase = (now_ms / 280) % 3; - let cy = rect.origin.y + rect.size.y / 2.0; for i in 0..3u64 { let active = i == phase; - let r = if active { 3.5 } else { 2.5 }; - let color = if active { - theme.foreground + let r = if active { + TYPING_DOT * 0.6 } else { - theme.muted_foreground + TYPING_DOT * 0.5 }; - let cx_dot = rect.origin.x + BUBBLE_PAD + 6.0 + i as f32 * 10.0; - cx.backend - .fill_oval(Rect::xywh(cx_dot - r, cy - r, r * 2.0, r * 2.0), color); + let mut color = theme.muted_foreground; + color.a *= if active { 0.9 } else { 0.7 }; + let cx_dot = start_x + r + i as f32 * (TYPING_DOT + TYPING_DOT_GAP); + cx.backend.fill_oval( + Rect::xywh(cx_dot - r, center_y - r, r * 2.0, r * 2.0), + color, + ); } } @@ -669,8 +706,25 @@ pub(crate) fn paint_transcript( ChatRole::Assistant => (theme.muted, theme.foreground), }; if bubble.typing { - cx.backend.fill_round_rect(bubble.rect, 8.0, bg); - paint_typing_dots(cx, theme, bubble.rect, now_ms); + cx.backend + .fill_round_rect(bubble.rect, bubble.rect.size.y / 2.0, bg); + let text_x = bubble.rect.origin.x + TYPING_PAD_X; + let baseline = bubble.rect.origin.y + TYPING_PAD_Y + 11.0; + draw_line( + cx, + TYPING_LABEL, + text_x, + baseline, + BODY_FONT, + theme.muted_foreground, + ); + paint_typing_dots( + cx, + theme, + text_x + text_unit_width(TYPING_LABEL) + TYPING_LABEL_DOT_GAP, + bubble.rect.origin.y + bubble.rect.size.y / 2.0, + now_ms, + ); } else { // Clip to the bubble — over-long tokens stay inside. cx.backend.save(); diff --git a/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs index 12022d36b..81f76057b 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs @@ -95,7 +95,9 @@ fn user_bubbles_remain_compact_and_right_aligned() { #[derive(Default)] struct TranscriptPaintBackend { - round_rects: usize, + round_rects: Vec<(Rect, f32)>, + ovals: usize, + texts: Vec, } impl crate::RenderBackend for TranscriptPaintBackend { @@ -103,17 +105,24 @@ impl crate::RenderBackend for TranscriptPaintBackend { fn end_frame(&mut self) {} fn fill_rect(&mut self, _: Rect, _: crate::Color) {} fn stroke_rect(&mut self, _: Rect, _: crate::Color, _: f32) {} - fn draw_text(&mut self, _: &crate::TextLayout, _: Point2D) {} + fn draw_text(&mut self, layout: &crate::TextLayout, _: Point2D) { + if let Some(run) = layout.runs().first() { + self.texts.push(run.content.clone()); + } + } fn clip_rect(&mut self, _: Rect) {} fn save(&mut self) {} fn restore(&mut self) {} fn translate(&mut self, _: Point2D) {} fn stroke_line(&mut self, _: Point2D, _: Point2D, _: crate::Color, _: f32) {} - fn fill_round_rect(&mut self, _: Rect, _: f32, _: crate::Color) { - self.round_rects += 1; + fn fill_round_rect(&mut self, rect: Rect, radius: f32, _: crate::Color) { + self.round_rects.push((rect, radius)); } fn stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {} fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: crate::Color, _: f32) {} + fn fill_oval(&mut self, _: Rect, _: crate::Color) { + self.ovals += 1; + } fn resize(&mut self, _: u32, _: u32) {} fn dpi_scale(&self) -> f32 { 1.0 @@ -137,7 +146,7 @@ fn paint_transcript_leaves_assistant_answer_unframed() { op_editor_core::Locale::EnUs, ); - assert_eq!(backend.round_rects, 0); + assert_eq!(backend.round_rects.len(), 0); } #[test] @@ -157,7 +166,7 @@ fn paint_transcript_keeps_user_answer_bubble_background() { op_editor_core::Locale::EnUs, ); - assert_eq!(backend.round_rects, 1); + assert_eq!(backend.round_rects.len(), 1); } #[test] @@ -169,6 +178,41 @@ fn streaming_message_with_no_text_yields_a_typing_bubble() { let bubble = items[0].bubble.as_ref().expect("typing bubble present"); assert!(bubble.typing, "empty in-flight message shows typing dots"); assert!(bubble.lines.is_empty()); + assert!( + bubble.rect.size.x < 120.0, + "TS renders the empty streaming state as a compact w-fit pill" + ); +} + +#[test] +fn paint_streaming_empty_assistant_shows_thinking_pill_label() { + let messages = [ChatMessage::assistant_streaming()]; + let mut backend = TranscriptPaintBackend::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + + paint_transcript( + &mut cx, + &crate::Theme::dark(), + body(), + &messages, + 0, + op_editor_core::Locale::EnUs, + ); + + assert_eq!(backend.round_rects.len(), 1); + let (pill, radius) = backend.round_rects[0]; + assert!(pill.size.x < 120.0, "typing pill should not be full width"); + assert!( + (radius - pill.size.y / 2.0).abs() < 1e-4, + "TS uses rounded-full for the streaming pill" + ); + assert!( + backend.texts.iter().any(|text| text == "Thinking"), + "TS shows the Thinking label before the animated dots" + ); + assert_eq!(backend.ovals, 3); } #[test]