diff --git a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs index d3fdd4514..3ca3a9c23 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs @@ -568,24 +568,15 @@ fn with_alpha(color: Color, a: f32) -> Color { } pub(crate) fn paint_key_glyph(cx: &mut PaintCx<'_>, top_left: Point2D, size: f32, color: Color) { - let cy = top_left.y + size * 0.5; - let ring = Rect { - origin: Point2D::new(top_left.x, cy - size * 0.28), - size: Point2D::new(size * 0.55, size * 0.55), - }; - cx.backend - .stroke_round_rect(ring, ring.size.x / 2.0, color, 1.3); - cx.backend.stroke_line( - Point2D::new(top_left.x + size * 0.52, cy), - Point2D::new(top_left.x + size, cy), + // Use the real lucide `Key` glyph (TS renders ``) rather than a + // hand-rolled ring+shaft approximation. + crate::widgets::icons::draw_icon( + cx.backend, + crate::widgets::icons::Icon::Key, + top_left, + size, color, - 1.3, - ); - cx.backend.stroke_line( - Point2D::new(top_left.x + size * 0.82, cy), - Point2D::new(top_left.x + size * 0.82, cy + size * 0.25), - color, - 1.3, + 1.4, ); } 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 6b70e223f..f1894319e 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -555,19 +555,50 @@ impl<'a> Widget for AIChatPlaceholder<'a> { 1.4, ); 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. let chip = footer.agent_team; - cx.backend.fill_round_rect(chip, 6.0, self.theme.muted); - if self.footer_hover == Some(ChatFooterButton::AgentTeam) { - cx.backend - .fill_round_rect(chip, 6.0, chat_neutral_hover_color(&self.theme)); + let team_size = self.state.agent_team_size; + let team_active = team_size > 1; + let conc_color = if team_active { + self.theme.primary + } else { + Color { + a: 0.5, + ..self.theme.muted_foreground + } + }; + let primary_wash = Color { + a: 0.1, + ..self.theme.primary + }; + if team_active { + cx.backend.fill_round_rect(chip, 6.0, primary_wash); } - let team_label = format!("{}x", self.state.agent_team_size); + if self.footer_hover == Some(ChatFooterButton::AgentTeam) { + let wash = if team_active { + primary_wash + } else { + chat_neutral_hover_color(&self.theme) + }; + cx.backend.fill_round_rect(chip, 6.0, wash); + } + draw_icon( + cx.backend, + Icon::Zap, + Point2D::new(chip.origin.x + 4.0, chip.origin.y + 5.0), + 10.0, + conc_color, + 1.4, + ); + let team_label = format!("{}x", team_size); draw_label( cx, &team_label, 11.0, - self.theme.muted_foreground, - chip.origin.x + 7.0, + conc_color, + chip.origin.x + 16.0, chip.origin.y + 14.0, ); model_x += 36.0; @@ -583,9 +614,9 @@ impl<'a> Widget for AIChatPlaceholder<'a> { toolbar_center_y + 4.0, ); + // Right cluster — attach + send (TS ghost buttons: bare icons + // that only get a wash while hovered). let attach_rect = footer.attach; - cx.backend - .fill_round_rect(attach_rect, 6.0, self.theme.muted); if self.footer_hover == Some(ChatFooterButton::AddAttachment) { cx.backend .fill_round_rect(attach_rect, 6.0, chat_neutral_hover_color(&self.theme)); @@ -604,26 +635,26 @@ impl<'a> Widget for AIChatPlaceholder<'a> { let send_active = can_use_model && (!self.state.input.trim().is_empty() || !self.state.pending_attachments.is_empty()); let streaming = self.is_streaming(); - let (send_bg, icon_color, send_icon) = if streaming { - ( - self.theme.destructive, - self.theme.primary_foreground, - Icon::Square, - ) + // TS: streaming → red stop; sendable → primary; otherwise a faded + // muted-foreground/30 icon so the send button reads as disabled. + let (icon_color, send_icon) = if streaming { + (self.theme.destructive, Icon::Square) } else if send_active { + (self.theme.primary, Icon::Send) + } else { ( - self.theme.primary, - self.theme.primary_foreground, + Color { + a: 0.3, + ..self.theme.muted_foreground + }, Icon::Send, ) - } else { - (self.theme.muted, self.theme.muted_foreground, Icon::Send) }; - cx.backend.fill_round_rect(send_rect, 6.0, send_bg); if self.footer_hover == Some(ChatFooterButton::Send) || self.footer_hover == Some(ChatFooterButton::Stop) { - paint_footer_hover_overlay(cx, send_rect, send_active || streaming, &self.theme); + cx.backend + .fill_round_rect(send_rect, 6.0, chat_neutral_hover_color(&self.theme)); } draw_icon( cx.backend, @@ -674,20 +705,6 @@ fn draw_label(cx: &mut PaintCx<'_>, text: &str, size: f32, color: Color, x: f32, cx.backend.draw_text(&label, Point2D::new(x, y)); } -fn paint_footer_hover_overlay(cx: &mut PaintCx<'_>, rect: Rect, bright: bool, theme: &Theme) { - let color = if bright { - Color { - r: 1.0, - g: 1.0, - b: 1.0, - a: 0.14, - } - } else { - chat_neutral_hover_color(theme) - }; - cx.backend.fill_round_rect(rect, 6.0, color); -} - fn chat_neutral_hover_color(theme: &Theme) -> Color { Color { r: theme.foreground.r, 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 2d9d57779..eab7ff282 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 @@ -371,9 +371,11 @@ fn paint_send_button_hover_adds_visible_feedback() { .filter(|(r, _, _)| rect_close(*r, send_rect)) .collect(); + // Ghost button: no resting fill, so hover feedback is the only + // wash painted over the send rect. assert!( - fills.len() >= 2, - "hovered send button should paint feedback over its base fill" + !fills.is_empty(), + "hovered send button should paint a hover wash" ); } @@ -839,6 +841,7 @@ struct PanelPaintBackend { round_rects: Vec<(Rect, f32, crate::Color)>, texts: Vec<(String, f32, jian_core::scene::Color, Point2D)>, svg_strokes: Vec<(Point2D, f32, crate::Color, f32)>, + svg_paths: Vec, stroke_lines: usize, } @@ -868,12 +871,13 @@ impl crate::RenderBackend for PanelPaintBackend { fn stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {} fn stroke_svg_path( &mut self, - _: &str, + d: &str, top_left: Point2D, size: f32, color: crate::Color, width: f32, ) { + self.svg_paths.push(d.to_string()); self.svg_strokes.push((top_left, size, color, width)); } fn resize(&mut self, _: u32, _: u32) {} @@ -903,9 +907,12 @@ fn paint_model_chip_uses_key_glyph_for_builtin_model() { panel.paint(&mut cx, rect); + let key_paths = crate::widgets::icons::Icon::Key.paths(); assert!( - backend.stroke_lines >= 2, - "built-in selected model chip should paint the TS-style Key glyph" + key_paths + .iter() + .all(|kp| backend.svg_paths.iter().any(|p| p == kp)), + "built-in selected model chip should paint the lucide Key glyph" ); } 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 6a3b640d9..988bcd71e 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 @@ -119,10 +119,10 @@ pub(crate) fn paint_panel_body_chrome(cx: &mut PaintCx<'_>, theme: &Theme, rect: 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, - ); + // Full-width divider above the input (TS `border-t` spans the whole + // panel, not just the padded content column). + cx.backend + .fill_rect(Rect::xywh(inner_x, sep_y, inner_w, 1.0), theme.border); } /// Paint the empty-state hint line + the 2×2 example-card grid.