diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs index 85b38db96..3a0cdce42 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -6,7 +6,7 @@ use crate::widgets::ai_chat_panel_controls::{ attachment_row_hit, paint_attachment_row, ATTACHMENT_ROW_HEIGHT, }; use crate::widgets::ai_chat_panel_paint::{ - example_card_rects, paint_examples, paint_panel_surface, + example_card_rects, paint_examples, paint_panel_body_chrome, paint_panel_surface, }; use crate::widgets::editor_state_ext::{theme_for, translate}; use crate::widgets::icons::{draw_icon, Icon}; @@ -182,7 +182,7 @@ impl<'a> AIChatPlaceholder<'a> { // TS stores the chat title as UI state and defaults it to // this English title even under a Chinese locale. label_new_chat: "New Chat".to_string(), - label_start_with_ai: translate(ui, "ai.tryExample").to_string(), + label_start_with_ai: translate(ui, "ai.startDesigning").to_string(), label_input_placeholder: translate(ui, "ai.designWithAgent").to_string(), label_tip_select_elements: translate(ui, "ai.tipSelectElements").to_string(), label_no_models: translate(ui, "ai.noModelsConnected").to_string(), @@ -462,6 +462,9 @@ impl<'a> Widget for AIChatPlaceholder<'a> { } paint_panel_surface(cx, &self.theme, rect); + let input_h = self.input_height(); + let sep_y = rect.origin.y + rect.size.y - input_h; + paint_panel_body_chrome(cx, &self.theme, rect, sep_y); // Expanded header. let header_y = rect.origin.y + 8.0; @@ -502,7 +505,6 @@ impl<'a> Widget for AIChatPlaceholder<'a> { ); // Body — either messages or examples. - let input_h = self.input_height(); let checklist_h = fixed_checklist_height(&self.state.messages); if self.state.messages.is_empty() { paint_examples( @@ -532,17 +534,6 @@ impl<'a> Widget for AIChatPlaceholder<'a> { ); } - // Separator hairline between body and input area - // (matches the TS panel's bottom-bordered body region). - let sep_y = rect.origin.y + rect.size.y - input_h; - cx.backend.fill_rect( - Rect { - origin: Point2D::new(rect.origin.x + PAD, sep_y), - size: Point2D::new(rect.size.x - PAD * 2.0, 1.0), - }, - self.theme.border, - ); - // Textarea region — borderless, 14 px to mirror the TS app's // textarea style. Long input wraps across up to `MAX_LINES` // visible rows; beyond that the view anchors to the bottom diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs index 9ebed97e1..4b5b4250c 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs @@ -34,6 +34,17 @@ fn from_editor_tracks_selection_count_for_toolbar() { assert_eq!(panel.selected_count, 2); } +#[test] +fn from_editor_uses_ts_start_designing_hint() { + let s = EditorState::new(); + let panel = AIChatPlaceholder::from_editor(&s); + + assert_eq!( + panel.label_start_with_ai, + op_i18n::translate(s.editor_ui.locale, "ai.startDesigning") + ); +} + /// Y-coordinate of the textarea's vertical center. fn textarea_center_y() -> f32 { AI_CHAT_HEIGHT - INPUT_BASE_HEIGHT + 1.0 + INPUT_AREA_HEIGHT / 2.0 @@ -189,3 +200,73 @@ fn body_rect_reserves_space_for_fixed_step_checklist() { "fixed step checklist should reserve bottom space outside transcript" ); } + +#[derive(Default)] +struct PanelPaintBackend { + fills: Vec<(Rect, crate::Color)>, +} + +impl crate::RenderBackend for PanelPaintBackend { + fn begin_frame(&mut self) {} + fn end_frame(&mut self) {} + fn fill_rect(&mut self, rect: Rect, color: crate::Color) { + self.fills.push((rect, color)); + } + fn stroke_rect(&mut self, _: Rect, _: crate::Color, _: f32) {} + fn draw_text(&mut self, _: &crate::TextLayout, _: Point2D) {} + 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) {} + fn stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {} + fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: crate::Color, _: f32) {} + fn resize(&mut self, _: u32, _: u32) {} + fn dpi_scale(&self) -> f32 { + 1.0 + } +} + +fn has_fill_rect(fills: &[(Rect, crate::Color)], expected: Rect) -> bool { + fills.iter().any(|(rect, _)| { + (rect.origin.x - expected.origin.x).abs() < 1e-4 + && (rect.origin.y - expected.origin.y).abs() < 1e-4 + && (rect.size.x - expected.size.x).abs() < 1e-4 + && (rect.size.y - expected.size.y).abs() < 1e-4 + }) +} + +#[test] +fn paint_draws_header_divider_and_message_body_background() { + let s = EditorState::new(); + let panel = AIChatPlaceholder::from_editor(&s); + let rect = Rect::xywh(10.0, 20.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); + let input_h = INPUT_BASE_HEIGHT; + let sep_y = rect.origin.y + rect.size.y - input_h; + let mut backend = PanelPaintBackend::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + + panel.paint(&mut cx, rect); + + assert!(has_fill_rect( + &backend.fills, + Rect::xywh( + rect.origin.x + 1.0, + rect.origin.y + HEADER_HEIGHT, + rect.size.x - 2.0, + 1.0 + ) + )); + assert!(has_fill_rect( + &backend.fills, + Rect::xywh( + rect.origin.x + 1.0, + rect.origin.y + HEADER_HEIGHT + 1.0, + rect.size.x - 2.0, + sep_y - (rect.origin.y + HEADER_HEIGHT + 1.0), + ) + )); +} diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs index 7b1ebe957..e21bf8d0a 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs @@ -54,6 +54,27 @@ pub(crate) fn paint_panel_surface(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rec cx.backend.stroke_round_rect(rect, 14.0, theme.border, 1.0); } +/// Paint the message body's TS-style background and internal dividers. +pub(crate) fn paint_panel_body_chrome(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rect, sep_y: f32) { + let inner_x = rect.origin.x + 1.0; + let inner_w = (rect.size.x - 2.0).max(0.0); + cx.backend.fill_rect( + Rect::xywh(inner_x, rect.origin.y + HEADER_HEIGHT, inner_w, 1.0), + theme.border, + ); + let body_y = rect.origin.y + HEADER_HEIGHT + 1.0; + if sep_y > body_y { + cx.backend.fill_rect( + Rect::xywh(inner_x, body_y, inner_w, sep_y - body_y), + with_alpha(theme.background, 0.8), + ); + } + cx.backend.fill_rect( + Rect::xywh(rect.origin.x + PAD, sep_y, rect.size.x - PAD * 2.0, 1.0), + theme.border, + ); +} + /// Paint the empty-state hint line + the 2×2 example-card grid. pub(crate) fn paint_examples( cx: &mut PaintCx<'_>, 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 dc6b05093..75c89cf7f 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs @@ -273,7 +273,11 @@ fn build_item( }) } else if !visible_content.is_empty() { let lines = wrap_units(&visible_content, budget); - let h = lines.len() as f32 * LINE_H + 2.0 * BUBBLE_PAD; + let h = if is_user { + lines.len() as f32 * LINE_H + 2.0 * BUBBLE_PAD + } else { + lines.len() as f32 * LINE_H + }; let r = Rect::xywh(x, y, bubble_w, h); y += h; Some(TextBubble { @@ -664,23 +668,27 @@ pub(crate) fn paint_transcript( ChatRole::User => (theme.primary, theme.primary_foreground), ChatRole::Assistant => (theme.muted, theme.foreground), }; - cx.backend.fill_round_rect(bubble.rect, 8.0, bg); if bubble.typing { + cx.backend.fill_round_rect(bubble.rect, 8.0, bg); paint_typing_dots(cx, theme, bubble.rect, now_ms); } else { // Clip to the bubble — over-long tokens stay inside. cx.backend.save(); cx.backend.clip_rect(bubble.rect); - let mut baseline = bubble.rect.origin.y + BUBBLE_PAD + 11.0; + if item.role == ChatRole::User { + cx.backend.fill_round_rect(bubble.rect, 8.0, bg); + } + let text_x = match item.role { + ChatRole::User => bubble.rect.origin.x + BUBBLE_PAD, + ChatRole::Assistant => bubble.rect.origin.x, + }; + let mut baseline = bubble.rect.origin.y + + match item.role { + ChatRole::User => BUBBLE_PAD + 11.0, + ChatRole::Assistant => 11.0, + }; for line in &bubble.lines { - draw_line( - cx, - line, - bubble.rect.origin.x + BUBBLE_PAD, - baseline, - BODY_FONT, - fg, - ); + draw_line(cx, line, text_x, baseline, BODY_FONT, fg); baseline += LINE_H; } // Streaming caret — a blinking bar after the last @@ -688,7 +696,7 @@ pub(crate) fn paint_transcript( if item.streaming && jian_core::anim::blink_visible(now_ms, 0, 500) { let last = bubble.lines.last().map(String::as_str).unwrap_or(""); let units: u32 = last.chars().map(char_display_units).sum(); - let caret_x = bubble.rect.origin.x + BUBBLE_PAD + units as f32 * CHAR_UNIT_PX; + let caret_x = text_x + units as f32 * CHAR_UNIT_PX; let caret_y = baseline - LINE_H - 9.0; cx.backend .fill_rect(Rect::xywh(caret_x, caret_y, 2.0, 13.0), fg); 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 8eef714b5..12022d36b 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 @@ -62,6 +62,20 @@ fn assistant_blocks_use_full_body_width_like_ts_transcript() { assert!((bubble.rect.size.x - body.size.x).abs() < 1e-4); } +#[test] +fn assistant_answer_uses_plain_text_height_without_bubble_padding() { + let msg = ChatMessage::assistant("first line\nsecond line"); + let items = build_transcript( + std::slice::from_ref(&msg), + body(), + op_editor_core::Locale::EnUs, + ); + let bubble = items[0].bubble.as_ref().expect("assistant answer text"); + + assert_eq!(bubble.lines.len(), 2); + assert!((bubble.rect.size.y - LINE_H * 2.0).abs() < 1e-4); +} + #[test] fn user_bubbles_remain_compact_and_right_aligned() { let msg = ChatMessage::user("user prompt"); @@ -79,6 +93,73 @@ fn user_bubbles_remain_compact_and_right_aligned() { ); } +#[derive(Default)] +struct TranscriptPaintBackend { + round_rects: usize, +} + +impl crate::RenderBackend for TranscriptPaintBackend { + fn begin_frame(&mut self) {} + 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 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 stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {} + fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: crate::Color, _: f32) {} + fn resize(&mut self, _: u32, _: u32) {} + fn dpi_scale(&self) -> f32 { + 1.0 + } +} + +#[test] +fn paint_transcript_leaves_assistant_answer_unframed() { + let messages = [ChatMessage::assistant("assistant answer")]; + 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, 0); +} + +#[test] +fn paint_transcript_keeps_user_answer_bubble_background() { + let messages = [ChatMessage::user("user prompt")]; + 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, 1); +} + #[test] fn streaming_message_with_no_text_yields_a_typing_bubble() { let msgs = vec![ChatMessage::assistant_streaming()];