fix(ai): align streaming thinking pill

This commit is contained in:
Fini 2026-05-31 03:23:13 +08:00
parent 86d8e7d352
commit 9620d94bc2
2 changed files with 117 additions and 19 deletions

View file

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

View file

@ -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<String>,
}
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]