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.