fix(ai): align streaming thinking pill
This commit is contained in:
parent
86d8e7d352
commit
9620d94bc2
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue