fix(ai): chat panel hover/close/send-slot refinements (#41/#42/#43)

- #41: collapse chevron renders through IconButton so it shows the ghost
  button-hover wash instead of only swapping the icon color
- #42: send/stop share one circle slot (toggle in place); footer cluster
  sits snug with no reserved stop gap; hit-test routes streaming->stop else send
- #43: example cards stay clickable without a connected model (clicking one
  fills the input; sending still requires a model)
- tab close glyph shows only while the tab is hovered (inset still reserved
  on the active tab so the title doesn't reflow)
This commit is contained in:
Kayshen-X 2026-07-02 21:21:08 +08:00
parent fc58a4601d
commit 89e5699e69
6 changed files with 148 additions and 135 deletions

View file

@ -464,22 +464,28 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
let header_icon_y = rect.origin.y + (HEADER_HEIGHT - 18.0) / 2.0;
let right_edge = rect.origin.x + rect.size.x - PAD;
let chevron_x = rect.origin.x + PAD;
let tokens = crate::widgets::button::tokens_from_theme(&self.theme);
// --- Collapse chevron (far left) ---
let chevron_hovered = self.header_hover == Some(ChatHeaderButton::ToggleCollapse);
let chevron_pressed = self.header_pressed == Some(ChatHeaderButton::ToggleCollapse);
let chevron_color = if chevron_hovered || chevron_pressed {
self.theme.foreground
} else {
self.theme.muted_foreground
};
draw_icon(
// Rendered through IconButton (like the maximize button beside the
// new-chat "+") so hovering/pressing it shows the same ghost
// button-hover wash instead of only swapping the icon color (#41).
jian_widgets::components::icon_button::IconButton {
icon_paths: Icon::ChevronDown.paths(),
hovered: self.header_hover == Some(ChatHeaderButton::ToggleCollapse),
pressed: self.header_pressed == Some(ChatHeaderButton::ToggleCollapse),
active: false,
enabled: true,
icon_size: 18.0,
stroke_width: 1.4,
}
.paint(
cx.backend,
Icon::ChevronDown,
Point2D::new(chevron_x, header_icon_y),
18.0,
chevron_color,
1.4,
Rect {
origin: Point2D::new(chevron_x, header_icon_y),
size: Point2D::new(18.0, 18.0),
},
&tokens,
);
// --- New-chat "+" circular button (far right, 28px circle) ---
@ -520,7 +526,6 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
// --- Maximize / minimize icon (just left of new-chat) ---
let maximize_x = right_edge - NEW_CHAT_D - MAXIMIZE_GAP - MAXIMIZE_W;
let tokens = crate::widgets::button::tokens_from_theme(&self.theme);
jian_widgets::components::icon_button::IconButton {
icon_paths: self.maximize_icon().paths(),
hovered: self.header_hover == Some(ChatHeaderButton::ToggleMaximize),
@ -563,7 +568,9 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
&self.label_start_with_ai,
&self.label_tip_select_elements,
&self.examples,
!can_use_model || self.is_streaming(),
// Examples stay enabled without a connected model (#43) — clicking
// one fills the input; only streaming disables them.
self.is_streaming(),
self.example_hover,
self.example_pressed,
);

View file

@ -140,11 +140,11 @@ fn hit_test_resolves_stop_at_right_while_streaming() {
.push(op_editor_core::ChatMessage::assistant_streaming());
let panel = AIChatPlaceholder::from_editor(&s);
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
// old→new: stop circle is a separate button at right_edge-30-6-30 = right_edge-66;
// center is at right_edge - 66 + 15 = right_edge - 51.
// old code put stop at the same position as send and used streaming to disambiguate;
// new code has dedicated stop and send buttons.
let stop_x = AI_CHAT_WIDTH - PAD - 51.0;
// #42: stop shares the send slot — the circle toggles send↑ ↔ stop◻ in
// place (center at right_edge - 15, same as `hit_test_resolves_send_at_right`).
// While streaming, a click on the single circle resolves to Stop because the
// hit-test checks `streaming && stop` before `send`.
let stop_x = AI_CHAT_WIDTH - PAD - 15.0;
let p = Point2D::new(stop_x, toolbar_center_y());
assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::Stop));
@ -576,7 +576,7 @@ pub(in super::super) fn has_fill_rect(fills: &[(Rect, crate::Color)], expected:
#[test]
fn bottom_toolbar_layout_send_is_rightmost_circle() {
// The send button is the rightmost element; stop is to its left.
// The send button is the rightmost element; stop shares its slot (#42).
let s = EditorState::new();
let panel = AIChatPlaceholder::from_editor(&s);
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
@ -589,9 +589,11 @@ fn bottom_toolbar_layout_send_is_rightmost_circle() {
(footer.send.size.x - footer.send.size.y).abs() < 0.01,
"send button must be circular"
);
// #42: stop is no longer a separate button left of send — it shares the
// send slot (the circle toggles send↑ ↔ stop◻ in place).
assert!(
footer.send.origin.x > footer.stop.origin.x,
"send must be to the right of stop"
(footer.stop.origin.x - footer.send.origin.x).abs() < 0.01,
"stop must share the send slot"
);
// Send right edge should match panel right minus PAD.
let right_edge = rect.origin.x + rect.size.x - PAD;
@ -634,9 +636,10 @@ fn bottom_toolbar_layout_model_pill_is_leftmost() {
}
#[test]
fn bottom_toolbar_layout_order_is_model_speed_attach_palette_stop_send() {
// #38: ⚡/📎/🎨 moved right — full left-to-right order is:
// model (LEFT) | [gap] | speed | attach | palette | stop | send (RIGHT)
fn bottom_toolbar_layout_order_is_model_speed_attach_palette_send() {
// #38: ⚡/📎/🎨 moved right; #42: stop shares the send slot. Full
// left-to-right order is:
// model (LEFT) | [gap] | speed | attach | palette | send (RIGHT)
let s = EditorState::new();
let panel = AIChatPlaceholder::from_editor(&s);
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
@ -644,17 +647,18 @@ fn bottom_toolbar_layout_order_is_model_speed_attach_palette_stop_send() {
let toolbar_top = input.origin.y + INPUT_AREA_HEIGHT;
let footer = panel.footer_layout(rect, input, toolbar_top);
// Left-to-right order: model < speed < attach < palette < stop < send
// Left-to-right order: model < speed < attach < palette < send
assert!(footer.model.origin.x < footer.speed.origin.x,
"model left of speed");
assert!(footer.speed.origin.x < footer.attach.origin.x,
"speed left of attach");
assert!(footer.attach.origin.x < footer.palette.origin.x,
"attach left of palette");
assert!(footer.palette.origin.x < footer.stop.origin.x,
"palette left of stop");
assert!(footer.stop.origin.x < footer.send.origin.x,
"stop left of send");
assert!(footer.palette.origin.x < footer.send.origin.x,
"palette left of send");
// #42: stop shares the send slot (toggle in place), not a separate button.
assert!((footer.stop.origin.x - footer.send.origin.x).abs() < 0.01,
"stop shares the send slot");
// #38 specific: speed/attach/palette must all be RIGHT of the model pill.
let model_right = footer.model.origin.x + footer.model.size.x;
assert!(footer.speed.origin.x > model_right + 4.0,
@ -685,7 +689,8 @@ fn hit_test_stop_circle_only_active_while_streaming() {
seed_available_model(&mut s2);
s2.chat.set_input_text("design");
let panel2 = AIChatPlaceholder::from_editor(&s2);
// When idle, stop rect position hits nothing (gap area), falling through to FocusInput.
// #42: the stop slot is the Send button while idle (stop shares it), so the
// same point resolves to Send — never Stop.
assert_ne!(
panel2.hit_test(rect, stop_center),
Some(AIChatHit::Stop),

View file

@ -167,35 +167,40 @@ fn paint_quick_action_card_pressed_uses_shared_feedback() {
#[test]
fn paint_send_button_hover_adds_visible_feedback() {
let mut s = EditorState::new();
seed_available_model(&mut s);
s.chat.set_input_text("design a login page");
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,
// The active send circle dims on hover (rest 1.0 → hover 0.9 alpha), so the
// hovered fill must visibly differ from the resting fill — not just exist.
let send_fill = |hovered: bool| -> crate::Color {
let mut s = EditorState::new();
seed_available_model(&mut s);
s.chat.set_input_text("design a login page");
if hovered {
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);
// Use footer_layout to get the exact rect rather than hardcoding.
let input = panel.input_rect(rect);
let toolbar_top = input.origin.y + INPUT_AREA_HEIGHT;
let footer = panel.footer_layout(rect, input, toolbar_top);
backend
.round_rects
.iter()
.filter(|(r, _, _)| rect_close(*r, footer.send))
.map(|(_, _, c)| *c)
.last()
.expect("send circle must paint a fill")
};
panel.paint(&mut cx, rect);
// old→new: send is now a 30px circle (#27 layout); resting fill always
// present (primary color), hover changes the alpha.
// Use footer_layout to get the exact rect rather than hardcoding.
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 fills: Vec<_> = backend
.round_rects
.iter()
.filter(|(r, _, _)| rect_close(*r, footer.send))
.collect();
// Send circle always paints a fill; verify at least one fill is present.
let resting = send_fill(false);
let hovered = send_fill(true);
assert!(
!fills.is_empty(),
"hovered send button should paint a fill"
!color_close(resting, hovered),
"hovered send fill must visibly differ from the resting fill"
);
}

View file

@ -1,13 +1,16 @@
//! Footer toolbar geometry and paint for the AI chat panel.
//!
//! Computes the bottom single-row toolbar rects:
//! model pill (LEFT) | [gap] | ⚡ parallel-agents chip | attach | palette | stop | send (RIGHT)
//! model pill (LEFT) | [gap] | ⚡ parallel-agents chip | attach | palette | send (RIGHT)
//!
//! As of #38 the ⚡/📎/🎨 cluster has moved from the LEFT (between model and gap) to
//! the RIGHT (immediately left of the stop/send circles). The model pill remains at PAD.
//! As of #38 the ⚡/📎/🎨 cluster moved from the LEFT (between model and gap) to
//! the RIGHT. As of #42 the cluster sits snug against the single send/stop
//! circle (no reserved stop gap). The model pill remains at PAD.
//!
//! The `stop` rect is always computed for paint/hit sync; the caller
//! decides whether to paint/test it based on `is_streaming()`.
//! The `stop` rect shares the `send` slot — the circle toggles send↑ ↔ stop◻
//! in place. The caller paints stop while streaming and send otherwise; the
//! hit-test checks `streaming && stop` before `send` so the same rect routes
//! to the right action.
//!
//! The ⚡ chip is the "PARALLEL AGENTS" chip (#32): shows `"{N}x"` in gold
//! where N = `ChatState::agent_team_size` (1–6). Clicking it opens a small
@ -70,8 +73,8 @@ impl<'a> AIChatPlaceholder<'a> {
// Agent-team chip — zero-width logical rect for schema compat; contains() = false.
let agent_team = Rect::xywh(model_x + FOOTER_MODEL_PILL_W, cy - 11.0, 0.0, 22.0);
// Right cluster (#38 layout) — ⚡ palette 📎 attach 🎨 palette | stop | send,
// laid out right-to-left from right_edge.
// Right cluster (#38/#42 layout) — ⚡ chip | 📎 attach | 🎨 palette | send,
// laid out right-to-left from right_edge (stop shares the send slot).
let right_edge = rect.origin.x + rect.size.x - PAD;
// Send circle — rightmost.
@ -82,16 +85,13 @@ impl<'a> AIChatPlaceholder<'a> {
FOOTER_CIRCLE_D,
);
// Stop circle — immediately left of send.
let stop = Rect::xywh(
right_edge - FOOTER_CIRCLE_D - FOOTER_GAP - FOOTER_CIRCLE_D,
cy - FOOTER_CIRCLE_D / 2.0,
FOOTER_CIRCLE_D,
FOOTER_CIRCLE_D,
);
// Stop circle — shares the send slot. The send arrow toggles to a
// stop square in place while streaming, so the icon cluster sits snug
// against the single circle with no reserved gap between 🎨 and send (#42).
let stop = send;
// Palette icon — immediately left of stop.
let palette_x = stop.origin.x - FOOTER_GAP - FOOTER_ICON_W;
// Palette icon — immediately left of the send/stop circle.
let palette_x = send.origin.x - FOOTER_GAP - FOOTER_ICON_W;
let palette =
Rect::xywh(palette_x, cy - FOOTER_ICON_W / 2.0, FOOTER_ICON_W, FOOTER_ICON_W);
@ -262,7 +262,7 @@ pub(crate) fn paint_parallel_agents_picker(
/// Paint the bottom-toolbar row of the AI chat panel (#27 / #32 layout).
///
/// Draws: model pill | ⚡ parallel-agents chip | 📎 attach | 🎨 palette | [gap] | ◻ stop | ↑ send
/// Draws: model pill | [gap] | ⚡ parallel-agents chip | 📎 attach | 🎨 palette | ↑ send (◻ stop while streaming)
///
/// The ⚡ chip shows "{N}x" in gold (N = `agent_team_size`) and opens the
/// Parallel Agents picker on click.
@ -488,49 +488,44 @@ pub(crate) fn paint_bottom_toolbar(
);
}
// --- Send circle (always shown) ---
let send_rect = footer.send;
let send_pressed = widget.footer_pressed == Some(ChatFooterButton::Send);
let send_hovered = widget.footer_hover == Some(ChatFooterButton::Send);
let (send_bg, send_icon_color) = if streaming {
// During streaming: send becomes a secondary indicator
// (stop handles cancellation); show it dimmed.
(
(widget.theme.primary).with_alpha(0.25),
Color {
a: 0.35,
..widget.theme.primary_foreground
},
)
} else if send_active {
let alpha = if send_pressed { 0.9 } else if send_hovered { 1.0 } else { 1.0 };
(
(widget.theme.primary).with_alpha(alpha),
widget.theme.primary_foreground,
)
} else {
// Disabled state — faded.
(
(widget.theme.muted).with_alpha(0.25),
Color {
a: 0.3,
..widget.theme.muted_foreground
},
)
};
cx.backend
.fill_round_rect(send_rect, FOOTER_CIRCLE_D / 2.0, send_bg);
// Up-arrow glyph at 12px, centered in the circle.
let send_glyph_size = 12.0;
draw_icon(
cx.backend,
Icon::ArrowUp,
Point2D::new(
send_rect.origin.x + (FOOTER_CIRCLE_D - send_glyph_size) / 2.0,
send_rect.origin.y + (FOOTER_CIRCLE_D - send_glyph_size) / 2.0,
),
send_glyph_size,
send_icon_color,
1.6,
);
// --- Send circle — shown only when NOT streaming; while a turn streams the
// stop circle above occupies the same slot (toggle in place, #42). ---
if !streaming {
let send_rect = footer.send;
let send_pressed = widget.footer_pressed == Some(ChatFooterButton::Send);
let send_hovered = widget.footer_hover == Some(ChatFooterButton::Send);
let (send_bg, send_icon_color) = if send_active {
// shadcn-style primary feedback: rest 1.0 → hover 0.9 → press 0.8
// (the panel bg shows through the dimmed alpha as a subtle darken).
let alpha = if send_pressed { 0.8 } else if send_hovered { 0.9 } else { 1.0 };
(
(widget.theme.primary).with_alpha(alpha),
widget.theme.primary_foreground,
)
} else {
// Disabled state — faded.
(
(widget.theme.muted).with_alpha(0.25),
Color {
a: 0.3,
..widget.theme.muted_foreground
},
)
};
cx.backend
.fill_round_rect(send_rect, FOOTER_CIRCLE_D / 2.0, send_bg);
// Up-arrow glyph at 12px, centered in the circle.
let send_glyph_size = 12.0;
draw_icon(
cx.backend,
Icon::ArrowUp,
Point2D::new(
send_rect.origin.x + (FOOTER_CIRCLE_D - send_glyph_size) / 2.0,
send_rect.origin.y + (FOOTER_CIRCLE_D - send_glyph_size) / 2.0,
),
send_glyph_size,
send_icon_color,
1.6,
);
}
}

View file

@ -273,14 +273,15 @@ pub(crate) fn paint_header_tabs(
},
1.5,
);
} else if show_right_inset {
// × close glyph — shown on active tab (when not running) and hovered tabs.
} else if is_hovered {
// × close glyph — shown ONLY while the tab is hovered (the inset is
// still reserved on the active tab so the title doesn't reflow).
draw_icon(
cx.backend,
Icon::Close,
Point2D::new(tr.close.origin.x, tr.close.origin.y),
CLOSE_W,
if is_hovered && !is_active {
if !is_active {
theme.foreground
} else {
theme.muted_foreground

View file

@ -317,8 +317,10 @@ impl<'a> AIChatPlaceholder<'a> {
return Some(hit.into());
}
}
if self.state.messages.is_empty() && can_use_model && !self.is_streaming() {
if self.state.messages.is_empty() && !self.is_streaming() {
// Examples grid hit-test (only rendered when no messages).
// Clickable regardless of model connection — clicking an example
// fills the input (sending separately requires a model) (#43).
for (index, (card, ex)) in example_card_rects(rect)
.iter()
.zip(self.examples.iter())
@ -451,23 +453,21 @@ impl<'a> AIChatPlaceholder<'a> {
return Some(op_editor_core::ChatFooterButton::Stop);
}
if (footer.send).contains(point) {
return Some(if streaming {
return None; // send is dimmed but hoverable — no wash needed
} else if !self.state.available_models.is_empty() {
op_editor_core::ChatFooterButton::Send
// #42: stop shares this slot and is matched above while streaming, so
// reaching here means we're idle — the circle is the Send button.
return if !self.state.available_models.is_empty() {
Some(op_editor_core::ChatFooterButton::Send)
} else {
return None;
});
None
};
}
None
}
pub fn example_hover_at(&self, rect: Rect, point: Point2D) -> Option<usize> {
if !self.state.messages.is_empty()
|| self.state.available_models.is_empty()
|| self.is_streaming()
|| self.state.collapsed
{
// Examples are hoverable/clickable regardless of model connection (#43);
// gate only on messages-empty / not-streaming / not-collapsed.
if !self.state.messages.is_empty() || self.is_streaming() || self.state.collapsed {
return None;
}
example_card_rects(rect)