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 9798bad53..147b849d1 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -585,13 +585,9 @@ impl<'a> Widget for AIChatPlaceholder<'a> { self.theme.muted_foreground, 1.4, ); -<<<<<<< HEAD - model_x += 18.0; // Concurrency chip — `Zap` + `{n}x`. A solo 1x team rests as a // faint ghost; a staffed team (>1) gets a primary-tinted chip so // the parallel mode reads as active. -======= ->>>>>>> 926f84b6 (feat(editor-ui): canvas + panel widget batch for TS parity) let chip = footer.agent_team; let team_size = self.state.agent_team_size; let team_active = team_size > 1; @@ -782,13 +778,10 @@ pub(crate) fn to_jian_color(c: Color) -> jian_core::scene::Color { #[cfg(test)] #[path = "ai_chat_panel/tests.rs"] mod tests; -<<<<<<< HEAD #[cfg(test)] #[path = "ai_chat_panel/tests_paint.rs"] mod tests_paint; -======= #[cfg(test)] #[path = "ai_chat_panel/tests_transcript.rs"] mod tests_transcript; ->>>>>>> 926f84b6 (feat(editor-ui): canvas + panel widget batch for TS parity) 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 00416738a..163dad552 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 @@ -2,6 +2,8 @@ //! Split into a sibling file to keep `ai_chat_panel.rs` under the //! 800-line cap. +#[allow(unused_imports)] +use super::tests_paint::{assert_close, color_close, rect_close}; use super::*; use crate::widgets::ai_chat_hit::{AIChatHit, ChatResizeEdge}; @@ -243,38 +245,6 @@ fn footer_agent_team_chip_is_clickable_and_hoverable() { ); } -#[test] -<<<<<<< HEAD -======= -fn footer_selection_count_sits_close_to_agent_team_chip() { - let mut s = EditorState::new(); - seed_available_model(&mut s); - s.selection.set = vec![op_editor_core::NodeId::new("n1")]; - s.selection.anchor = op_editor_core::NodeId::new("n1"); - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let input = panel.input_rect(rect); - let toolbar_top = input.origin.y + INPUT_AREA_HEIGHT; - let footer = panel.footer_layout(rect, input, toolbar_top); - let mut backend = PanelPaintBackend::default(); - let mut cx = PaintCx { - backend: &mut backend, - }; - - panel.paint(&mut cx, rect); - - let selected_text = - op_i18n::translate(panel.locale, "common.selected").replace("{{count}}", "1"); - let (_, _, _, origin) = backend - .texts - .iter() - .find(|(text, _, _, _)| text == &selected_text) - .expect("footer should paint selected-count label"); - let gap = origin.x - (footer.agent_team.origin.x + footer.agent_team.size.x); - assert_close(gap, 4.0); -} - -#[test] fn multiline_input_expands_above_footer_toolbar() { let mut s = EditorState::new(); s.chat.input = @@ -289,140 +259,6 @@ fn multiline_input_expands_above_footer_toolbar() { assert!(panel.input_height() > INPUT_BASE_HEIGHT); } -#[test] -fn paint_send_button_hover_adds_visible_feedback() { - let mut s = EditorState::new(); - seed_available_model(&mut s); - s.chat.input = "design a login page".into(); - s.editor_ui.chat_footer_hover = Some(op_editor_core::ChatFooterButton::Send); - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let mut backend = PanelPaintBackend::default(); - let mut cx = PaintCx { - backend: &mut backend, - }; - - panel.paint(&mut cx, rect); - - let send_rect = Rect { - origin: Point2D::new(AI_CHAT_WIDTH - PAD - 24.0, toolbar_center_y() - 24.0 / 2.0), - size: Point2D::new(24.0, 24.0), - }; - let fills: Vec<_> = backend - .round_rects - .iter() - .filter(|(r, _, _)| rect_close(*r, send_rect)) - .collect(); - - assert!( - fills.len() >= 2, - "hovered send button should paint feedback over its base fill" - ); -} - -#[test] -fn paint_footer_neutral_hovers_use_visible_feedback() { - let cases = [ - op_editor_core::ChatFooterButton::ModelPicker, - op_editor_core::ChatFooterButton::AgentTeam, - op_editor_core::ChatFooterButton::AddAttachment, - op_editor_core::ChatFooterButton::Send, - ]; - - for hover in cases { - let mut s = EditorState::new(); - seed_available_model(&mut s); - s.editor_ui.chat_footer_hover = Some(hover); - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let input = panel.input_rect(rect); - let toolbar_top = input.origin.y + INPUT_AREA_HEIGHT; - let footer = panel.footer_layout(rect, input, toolbar_top); - let target = match hover { - op_editor_core::ChatFooterButton::ModelPicker => footer.model, - op_editor_core::ChatFooterButton::AgentTeam => footer.agent_team, - op_editor_core::ChatFooterButton::AddAttachment => footer.attach, - op_editor_core::ChatFooterButton::Send => footer.send, - op_editor_core::ChatFooterButton::Stop => unreachable!(), - }; - let mut backend = PanelPaintBackend::default(); - let mut cx = PaintCx { - backend: &mut backend, - }; - - panel.paint(&mut cx, rect); - - assert!( - backend.round_rects.iter().any(|(r, _, color)| { - rect_close(*r, target) - && !color_close(*color, panel.theme.muted) - && color.a > panel.theme.button_hover.a + 0.01 - }), - "{hover:?} hover should paint a visible neutral wash" - ); - } -} - -#[test] -fn paint_model_picker_hover_stays_inside_model_chip() { - let mut s = EditorState::new(); - seed_available_model(&mut s); - s.editor_ui.chat_footer_hover = Some(op_editor_core::ChatFooterButton::ModelPicker); - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let input = panel.input_rect(rect); - let toolbar_top = input.origin.y + INPUT_AREA_HEIGHT; - let footer = panel.footer_layout(rect, input, toolbar_top); - let mut backend = PanelPaintBackend::default(); - let mut cx = PaintCx { - backend: &mut backend, - }; - - panel.paint(&mut cx, rect); - - let hover = backend - .round_rects - .iter() - .find(|(r, _, color)| { - rect_close(*r, footer.model) - && color_close(*color, chat_neutral_hover_color(&panel.theme)) - }) - .expect("model picker hover should paint a visible wash"); - - assert!( - hover.0.origin.x + hover.0.size.x <= footer.agent_team.origin.x - 6.0, - "model hover should leave visible spacing before the Agent Team chip" - ); -} - -#[test] -fn paint_expanded_header_title_hover_adds_visible_feedback_across_label() { - let mut s = EditorState::new(); - s.editor_ui.chat_header_hover = Some(op_editor_core::ChatHeaderButton::ToggleCollapse); - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let mut backend = PanelPaintBackend::default(); - let mut cx = PaintCx { - backend: &mut backend, - }; - - panel.paint(&mut cx, rect); - - assert!( - backend.round_rects.iter().any(|(r, _, color)| { - r.origin.x <= PAD - && r.origin.y <= 6.0 - && r.size.x >= 108.0 - && r.size.y >= 28.0 - && r.size.y <= 34.0 - && color_close(*color, chat_neutral_hover_color(&panel.theme)) - }), - "expanded New Chat title hover should cover the label, not only the chevron" - ); -} - -#[test] ->>>>>>> 926f84b6 (feat(editor-ui): canvas + panel widget batch for TS parity) fn hit_test_resolves_model_search_clear_button() { let mut s = EditorState::new(); seed_available_model(&mut s); @@ -599,184 +435,6 @@ fn hit_test_resolves_header_new_chat_button() { assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::NewChat)); } -<<<<<<< HEAD -#[test] -fn body_rect_reserves_space_for_fixed_step_checklist() { - let mut s = EditorState::new(); - let mut message = op_editor_core::ChatMessage::assistant_streaming(); - message.content = - r#"Analyzing request..."# - .into(); - s.chat.messages.push(message); - - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let body = panel.body_rect(rect); - let legacy_bottom = rect.origin.y + rect.size.y - INPUT_BASE_HEIGHT - PAD - 8.0; - - assert!( - body.origin.y + body.size.y < legacy_bottom - 1.0, - "fixed step checklist should reserve bottom space outside transcript" - ); -} - -#[test] -fn default_height_preserves_transcript_space_above_full_checklist() { - let mut s = EditorState::new(); - let mut message = op_editor_core::ChatMessage::assistant_streaming(); - message.content = (0..17) - .map(|idx| { - format!(r#"Generated section {idx}"#) - }) - .collect::>() - .join("\n"); - s.chat.messages.push(message); - - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let body = panel.body_rect(rect); - - assert!( - body.size.y >= 150.0, - "default chat panel height should leave room for prior chat above the pinned checklist, got {}", - body.size.y - ); -} - -#[test] -fn body_rect_reserves_less_space_when_fixed_step_checklist_collapsed() { - let mut expanded_state = EditorState::new(); - let mut message = op_editor_core::ChatMessage::assistant_streaming(); - message.content = r#" -"# - .into(); - expanded_state.chat.messages.push(message.clone()); - - let mut collapsed_state = EditorState::new(); - collapsed_state.chat.messages.push(message); - collapsed_state.chat.checklist_collapsed = true; - - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let expanded = AIChatPlaceholder::from_editor(&expanded_state).body_rect(rect); - let collapsed = AIChatPlaceholder::from_editor(&collapsed_state).body_rect(rect); - - assert!(collapsed.size.y > expanded.size.y); -} - -#[test] -fn hit_test_resolves_fixed_checklist_header_toggle() { - let mut s = EditorState::new(); - let mut message = op_editor_core::ChatMessage::assistant_streaming(); - message.content = r#" -"# - .into(); - s.chat.messages.push(message); - - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let checklist_h = fixed_checklist_height(&s.chat.messages, s.chat.checklist_collapsed); - let checklist = fixed_checklist_rect(rect, INPUT_BASE_HEIGHT, checklist_h); - let p = Point2D::new( - checklist.origin.x + checklist.size.x / 2.0, - checklist.origin.y + 2.0 + 32.0 / 2.0, - ); - - assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::ToggleChecklist)); -} - -#[test] -fn hit_test_resolves_individual_tool_card_header_toggle() { - let mut s = EditorState::new(); - let mut message = op_editor_core::ChatMessage::assistant("answer"); - message.tools_collapsed = false; - message.tool_calls.push(op_editor_core::ChatToolCall { - name: "snapshot_layout".into(), - args: r#"{"args":{"pageId":"page-1"}}"#.into(), - }); - s.chat.messages.push(message); - - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let card_header = crate::widgets::ai_chat_transcript::build_transcript( - &s.chat.messages, - panel.body_rect(rect), - panel.locale, - )[0] - .tools - .as_ref() - .unwrap() - .cards[0] - .header; - let p = Point2D::new( - card_header.origin.x + card_header.size.x / 2.0, - card_header.origin.y + card_header.size.y / 2.0, - ); - - assert_eq!( - panel.hit_test(rect, p), - Some(AIChatHit::SetToolCallCardExpanded(0, 0, true)) - ); -} - -#[test] -fn hit_test_resolves_design_block_header_toggle() { - let mut s = EditorState::new(); - s.chat.messages.push(op_editor_core::ChatMessage::assistant( - r#"```json -[{"id":"frame-1","type":"Frame"}] -```"#, - )); - - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let header = crate::widgets::ai_chat_transcript::build_transcript( - &s.chat.messages, - panel.body_rect(rect), - panel.locale, - )[0] - .design_blocks[0] - .header; - let p = Point2D::new( - header.origin.x + header.size.x / 2.0, - header.origin.y + header.size.y / 2.0, - ); - - assert_eq!( - panel.hit_test(rect, p), - Some(AIChatHit::SetDesignBlockExpanded(0, 0, true)) - ); -} - -#[test] -fn hit_test_resolves_design_block_copy_button() { - let code = r#"[{"id":"frame-1","type":"Frame"}]"#; - let mut s = EditorState::new(); - s.chat - .messages - .push(op_editor_core::ChatMessage::assistant(format!( - r#"```json -{code} -```"# - ))); - - let panel = AIChatPlaceholder::from_editor(&s); - let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); - let block = &crate::widgets::ai_chat_transcript::build_transcript( - &s.chat.messages, - panel.body_rect(rect), - panel.locale, - )[0] - .design_blocks[0]; - let p = Point2D::new( - block.header.origin.x + block.header.size.x - 38.0, - block.header.origin.y + block.header.size.y / 2.0, - ); - - assert_eq!( - panel.hit_test(rect, p), - Some(AIChatHit::CopyDesignBlock(code.to_string())) - ); -======= // Shared paint-assertion infrastructure — also used by the // sibling `tests_transcript` module (split at the 800-line cap). #[derive(Default)] @@ -835,5 +493,4 @@ pub(in super::super) fn has_fill_rect(fills: &[(Rect, crate::Color)], expected: && (rect.size.x - expected.size.x).abs() < 1e-4 && (rect.size.y - expected.size.y).abs() < 1e-4 }) ->>>>>>> 926f84b6 (feat(editor-ui): canvas + panel widget batch for TS parity) } diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_paint.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_paint.rs index 64e16882a..be05b7435 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_paint.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_paint.rs @@ -6,21 +6,21 @@ use super::tests::{seed_available_model, toolbar_center_y}; use super::*; -fn assert_close(actual: f32, expected: f32) { +pub(in super::super) fn assert_close(actual: f32, expected: f32) { assert!( (actual - expected).abs() < 1e-4, "expected {actual} to be close to {expected}" ); } -fn rect_close(actual: Rect, expected: Rect) -> bool { +pub(in super::super) fn rect_close(actual: Rect, expected: Rect) -> bool { (actual.origin.x - expected.origin.x).abs() < 0.01 && (actual.origin.y - expected.origin.y).abs() < 0.01 && (actual.size.x - expected.size.x).abs() < 0.01 && (actual.size.y - expected.size.y).abs() < 0.01 } -fn color_close(actual: crate::Color, expected: crate::Color) -> bool { +pub(in super::super) fn color_close(actual: crate::Color, expected: crate::Color) -> bool { (actual.r - expected.r).abs() < 0.001 && (actual.g - expected.g).abs() < 0.001 && (actual.b - expected.b).abs() < 0.001 @@ -217,6 +217,35 @@ fn paint_model_picker_hover_stays_inside_model_chip() { ); } +#[test] +fn footer_selection_count_sits_close_to_agent_team_chip() { + let mut s = EditorState::new(); + seed_available_model(&mut s); + s.selection.set = vec![op_editor_core::NodeId::new("n1")]; + s.selection.anchor = op_editor_core::NodeId::new("n1"); + let panel = AIChatPlaceholder::from_editor(&s); + let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); + let input = panel.input_rect(rect); + let toolbar_top = input.origin.y + INPUT_AREA_HEIGHT; + let footer = panel.footer_layout(rect, input, toolbar_top); + let mut backend = PanelPaintBackend::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + + panel.paint(&mut cx, rect); + + let selected_text = + op_i18n::translate(panel.locale, "common.selected").replace("{{count}}", "1"); + let (_, _, _, origin) = backend + .texts + .iter() + .find(|(text, _, _, _)| text == &selected_text) + .expect("footer should paint selected-count label"); + let gap = origin.x - (footer.agent_team.origin.x + footer.agent_team.size.x); + assert_close(gap, 4.0); +} + #[test] fn paint_expanded_header_title_hover_adds_visible_feedback_across_label() { let mut s = EditorState::new();