From 89e5699e69f52cecb6ea610ecf8a65e3601c3313 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 2 Jul 2026 21:21:08 +0800 Subject: [PATCH] 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) --- .../op-editor-ui/src/widgets/ai_chat_panel.rs | 37 +++--- .../src/widgets/ai_chat_panel/tests.rs | 39 +++--- .../src/widgets/ai_chat_panel/tests_paint.rs | 57 +++++---- .../src/widgets/ai_chat_panel_footer.rs | 119 +++++++++--------- .../src/widgets/ai_chat_panel_header.rs | 7 +- .../src/widgets/ai_chat_panel_hit.rs | 24 ++-- 6 files changed, 148 insertions(+), 135 deletions(-) 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 cd8704678..6c5eb7cd8 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -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, ); 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 00be912e1..d979f8f45 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 @@ -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), 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 cf8a0fe04..2a55106c8 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 @@ -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" ); } diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_footer.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_footer.rs index 4c198893a..7d98ab11d 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_footer.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_footer.rs @@ -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, + ); + } } diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_header.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_header.rs index bc618e6f6..921c124d4 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_header.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_header.rs @@ -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 diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs index 1acb649c6..181454d26 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs @@ -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 { - 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)