fix(editor-ui): resolve conflict markers swept into the chat panel during the rebase

The background fmt commit captured the editor-ui pick's conflicted state
mid-rebase; both sides kept (ab-v9 concurrency-chip spacing + tests_paint
module, batch tests_transcript module + footer tests), paint helpers
promoted for cross-module reuse.
This commit is contained in:
Kayshen-X 2026-06-12 23:36:36 +08:00
parent 14482a5d86
commit 9fd78d2d8a
3 changed files with 34 additions and 355 deletions

View file

@ -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)

View file

@ -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#"<step title="Checking guidelines" status="streaming">Analyzing request...</step>"#
.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#"<step title="Subtask {idx}" status="done">Generated section {idx}</step>"#)
})
.collect::<Vec<_>>()
.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#"<step title="Plan" status="done"></step>
<step title="Draw" status="streaming"></step>"#
.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#"<step title="Plan" status="done"></step>
<step title="Draw" status="streaming"></step>"#
.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)
}

View file

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