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:
parent
fc58a4601d
commit
89e5699e69
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue