mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
fix(ai): disable chat actions without models
This commit is contained in:
parent
1d2cc87d7c
commit
e8be687ca4
6 changed files with 129 additions and 41 deletions
|
|
@ -35,9 +35,6 @@ pub(crate) struct ExampleCard {
|
||||||
pub(crate) emoji: &'static str,
|
pub(crate) emoji: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the 4 empty-state example cards localised against `locale`.
|
|
||||||
/// Titles / subtitles / prompts come from the `ai.quickAction.*`
|
|
||||||
/// table; emojis stay literal.
|
|
||||||
pub(crate) fn example_cards(locale: op_editor_core::Locale) -> [ExampleCard; 4] {
|
pub(crate) fn example_cards(locale: op_editor_core::Locale) -> [ExampleCard; 4] {
|
||||||
let t = |key: &'static str| op_i18n::translate(locale, key).to_string();
|
let t = |key: &'static str| op_i18n::translate(locale, key).to_string();
|
||||||
[
|
[
|
||||||
|
|
@ -72,45 +69,30 @@ pub struct AIChatPlaceholder<'a> {
|
||||||
pub id: WidgetId,
|
pub id: WidgetId,
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
pub state: &'a ChatState,
|
pub state: &'a ChatState,
|
||||||
/// Host frame timestamp in ms; drives caret blink.
|
|
||||||
pub now_ms: u64,
|
pub now_ms: u64,
|
||||||
/// Localised chrome strings — resolved at construction time
|
|
||||||
/// from `Document::t` so the panel reflows when the user
|
|
||||||
/// flips the TopBar Globe icon.
|
|
||||||
pub label_new_chat: String,
|
pub label_new_chat: String,
|
||||||
pub label_start_with_ai: String,
|
pub label_start_with_ai: String,
|
||||||
pub label_input_placeholder: String,
|
pub label_input_placeholder: String,
|
||||||
/// Empty-state tip line below the example cards.
|
|
||||||
pub label_tip_select_elements: String,
|
pub label_tip_select_elements: String,
|
||||||
/// Chip label shown when no model is selected / discovered yet
|
|
||||||
/// (`ai.noModelsConnected`).
|
|
||||||
pub label_no_models: String,
|
pub label_no_models: String,
|
||||||
/// Number of currently selected canvas nodes, shown in the
|
/// Number of currently selected canvas nodes, shown in the
|
||||||
/// bottom toolbar like the TS panel.
|
/// bottom toolbar like the TS panel.
|
||||||
pub(crate) selected_count: usize,
|
pub(crate) selected_count: usize,
|
||||||
/// Whether the model-picker dropdown is open
|
/// Whether the model-picker dropdown is open.
|
||||||
/// (`Document.ui.chat_model_picker_open`). The picker lists
|
|
||||||
/// `state.available_models`; the active row is
|
|
||||||
/// `state.selected_model`.
|
|
||||||
pub model_picker_open: bool,
|
pub model_picker_open: bool,
|
||||||
/// Vertical scroll offset of the open model-picker dropdown, in
|
/// Vertical scroll offset of the open model-picker dropdown.
|
||||||
/// px (`Document.ui.chat_model_picker_scroll`).
|
|
||||||
pub model_picker_scroll: f32,
|
pub model_picker_scroll: f32,
|
||||||
/// Index into `state.available_models` of the picker row under
|
/// Index into `state.available_models` of the picker row under the cursor.
|
||||||
/// the cursor (`Document.ui.chat_model_picker_hover`).
|
|
||||||
pub model_picker_hover: Option<usize>,
|
pub model_picker_hover: Option<usize>,
|
||||||
/// Live model-picker search query
|
/// Live model-picker search query.
|
||||||
/// (`Document.ui.chat_model_picker_search`).
|
|
||||||
pub model_picker_search: String,
|
pub model_picker_search: String,
|
||||||
/// Byte caret for the model-picker search query.
|
/// Byte caret for the model-picker search query.
|
||||||
pub model_picker_caret: Option<usize>,
|
pub model_picker_caret: Option<usize>,
|
||||||
/// Last focus / edit timestamp for the model-picker search caret.
|
/// Last focus / edit timestamp for the model-picker search caret.
|
||||||
pub model_picker_caret_anchor_ms: u64,
|
pub model_picker_caret_anchor_ms: u64,
|
||||||
/// Localised empty-state example cards — resolved at construction
|
/// Localised empty-state example cards.
|
||||||
/// time so the grid reflows when the user flips the Globe icon.
|
|
||||||
pub(crate) examples: [ExampleCard; 4],
|
pub(crate) examples: [ExampleCard; 4],
|
||||||
/// Active UI locale — threaded into the transcript layout /
|
/// Active UI locale.
|
||||||
/// hit-test so the thinking / tool-call headers translate.
|
|
||||||
pub(crate) locale: op_editor_core::Locale,
|
pub(crate) locale: op_editor_core::Locale,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,8 +101,6 @@ impl<'a> AIChatPlaceholder<'a> {
|
||||||
Self::from_editor_at(state, 0)
|
Self::from_editor_at(state, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as `from_editor` but threads through the host's
|
|
||||||
/// current millisecond timestamp so the caret can blink.
|
|
||||||
pub fn from_editor_at(state: &'a EditorState, now_ms: u64) -> Self {
|
pub fn from_editor_at(state: &'a EditorState, now_ms: u64) -> Self {
|
||||||
let ui = &state.editor_ui;
|
let ui = &state.editor_ui;
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -226,6 +206,7 @@ impl<'a> AIChatPlaceholder<'a> {
|
||||||
if self.state.collapsed {
|
if self.state.collapsed {
|
||||||
return Some(AIChatHit::ToggleCollapse);
|
return Some(AIChatHit::ToggleCollapse);
|
||||||
}
|
}
|
||||||
|
let can_use_model = !self.state.available_models.is_empty();
|
||||||
// Expanded: chevron-down at top-left toggles collapse.
|
// Expanded: chevron-down at top-left toggles collapse.
|
||||||
let chevron_rect = Rect {
|
let chevron_rect = Rect {
|
||||||
origin: rect.origin,
|
origin: rect.origin,
|
||||||
|
|
@ -265,7 +246,7 @@ impl<'a> AIChatPlaceholder<'a> {
|
||||||
// open it behaves modally: a row click selects, any other
|
// open it behaves modally: a row click selects, any other
|
||||||
// click dismisses it. Hit-tested before the input so a row
|
// click dismisses it. Hit-tested before the input so a row
|
||||||
// click isn't eaten by the message list beneath.
|
// click isn't eaten by the message list beneath.
|
||||||
if self.model_picker_open {
|
if self.model_picker_open && can_use_model {
|
||||||
let picker = self.model_picker_rect(rect, input_rect);
|
let picker = self.model_picker_rect(rect, input_rect);
|
||||||
if crate::widgets::ai_chat_model_picker::search_clear_hit(
|
if crate::widgets::ai_chat_model_picker::search_clear_hit(
|
||||||
picker,
|
picker,
|
||||||
|
|
@ -311,7 +292,11 @@ impl<'a> AIChatPlaceholder<'a> {
|
||||||
// send icon buttons on the right.
|
// send icon buttons on the right.
|
||||||
if point.y >= toolbar_top {
|
if point.y >= toolbar_top {
|
||||||
if point.x <= input_rect.origin.x + MODEL_CHIP_W {
|
if point.x <= input_rect.origin.x + MODEL_CHIP_W {
|
||||||
return Some(AIChatHit::ToggleModelPicker);
|
return Some(if can_use_model {
|
||||||
|
AIChatHit::ToggleModelPicker
|
||||||
|
} else {
|
||||||
|
AIChatHit::FocusInput
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let send_x = input_rect.origin.x + input_rect.size.x - 32.0;
|
let send_x = input_rect.origin.x + input_rect.size.x - 32.0;
|
||||||
let attach_x = send_x - 32.0;
|
let attach_x = send_x - 32.0;
|
||||||
|
|
@ -321,8 +306,13 @@ impl<'a> AIChatPlaceholder<'a> {
|
||||||
if point.x >= send_x {
|
if point.x >= send_x {
|
||||||
return Some(if self.is_streaming() {
|
return Some(if self.is_streaming() {
|
||||||
AIChatHit::Stop
|
AIChatHit::Stop
|
||||||
} else {
|
} else if can_use_model
|
||||||
|
&& (!self.state.input.trim().is_empty()
|
||||||
|
|| !self.state.pending_attachments.is_empty())
|
||||||
|
{
|
||||||
AIChatHit::Send
|
AIChatHit::Send
|
||||||
|
} else {
|
||||||
|
AIChatHit::FocusInput
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -366,7 +356,7 @@ impl<'a> AIChatPlaceholder<'a> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.state.messages.is_empty() {
|
if self.state.messages.is_empty() && can_use_model && !self.is_streaming() {
|
||||||
// Examples grid hit-test (only rendered when no messages).
|
// Examples grid hit-test (only rendered when no messages).
|
||||||
for (card, ex) in example_card_rects(rect).iter().zip(self.examples.iter()) {
|
for (card, ex) in example_card_rects(rect).iter().zip(self.examples.iter()) {
|
||||||
if rect_contains(*card, point) {
|
if rect_contains(*card, point) {
|
||||||
|
|
@ -449,6 +439,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
paint_panel_surface(cx, &self.theme, rect);
|
paint_panel_surface(cx, &self.theme, rect);
|
||||||
|
let can_use_model = !self.state.available_models.is_empty();
|
||||||
let input_h = self.input_height();
|
let input_h = self.input_height();
|
||||||
let sep_y = rect.origin.y + rect.size.y - input_h;
|
let sep_y = rect.origin.y + rect.size.y - input_h;
|
||||||
paint_panel_body_chrome(cx, &self.theme, rect, sep_y);
|
paint_panel_body_chrome(cx, &self.theme, rect, sep_y);
|
||||||
|
|
@ -502,6 +493,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
||||||
&self.label_start_with_ai,
|
&self.label_start_with_ai,
|
||||||
&self.label_tip_select_elements,
|
&self.label_tip_select_elements,
|
||||||
&self.examples,
|
&self.examples,
|
||||||
|
!can_use_model || self.is_streaming(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
crate::widgets::ai_chat_transcript::paint_transcript(
|
crate::widgets::ai_chat_transcript::paint_transcript(
|
||||||
|
|
@ -723,8 +715,8 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
||||||
};
|
};
|
||||||
// A turn is sendable with text, with staged attachments, or
|
// A turn is sendable with text, with staged attachments, or
|
||||||
// both (TS parity: an attachment-only message is valid).
|
// both (TS parity: an attachment-only message is valid).
|
||||||
let send_active =
|
let send_active = can_use_model
|
||||||
!self.state.input.trim().is_empty() || !self.state.pending_attachments.is_empty();
|
&& (!self.state.input.trim().is_empty() || !self.state.pending_attachments.is_empty());
|
||||||
let streaming = self.is_streaming();
|
let streaming = self.is_streaming();
|
||||||
let (send_bg, icon_color, send_icon) = if streaming {
|
let (send_bg, icon_color, send_icon) = if streaming {
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,16 @@ fn toolbar_center_y() -> f32 {
|
||||||
AI_CHAT_HEIGHT - INPUT_BASE_HEIGHT + 1.0 + INPUT_AREA_HEIGHT + INPUT_TOOLBAR_HEIGHT / 2.0
|
AI_CHAT_HEIGHT - INPUT_BASE_HEIGHT + 1.0 + INPUT_AREA_HEIGHT + INPUT_TOOLBAR_HEIGHT / 2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn seed_available_model(s: &mut EditorState) {
|
||||||
|
s.chat
|
||||||
|
.available_models
|
||||||
|
.push(op_editor_core::chat::ModelEntry::new(
|
||||||
|
op_editor_core::chat::AgentProvider::CodexCli,
|
||||||
|
"gpt-5",
|
||||||
|
"GPT-5",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hit_test_resolves_input_focus() {
|
fn hit_test_resolves_input_focus() {
|
||||||
let s = EditorState::new();
|
let s = EditorState::new();
|
||||||
|
|
@ -66,10 +76,45 @@ fn hit_test_resolves_input_focus() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hit_test_resolves_send_at_right() {
|
fn no_model_disables_send_hit() {
|
||||||
|
let mut s = EditorState::new();
|
||||||
|
s.chat.input = "design a login page".into();
|
||||||
|
let panel = AIChatPlaceholder::from_editor(&s);
|
||||||
|
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
||||||
|
let send_x = AI_CHAT_WIDTH - PAD - 20.0;
|
||||||
|
let p = Point2D::new(send_x, toolbar_center_y());
|
||||||
|
|
||||||
|
assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::FocusInput));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_model_disables_quick_action_cards() {
|
||||||
let s = EditorState::new();
|
let s = EditorState::new();
|
||||||
let panel = AIChatPlaceholder::from_editor(&s);
|
let panel = AIChatPlaceholder::from_editor(&s);
|
||||||
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
||||||
|
let card_w = (AI_CHAT_WIDTH - PAD * 2.0 - 8.0) / 2.0;
|
||||||
|
let p = Point2D::new(PAD + card_w / 2.0, HEADER_HEIGHT + 32.0 + 35.0);
|
||||||
|
|
||||||
|
assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::DragHandle));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_model_disables_model_picker_toggle() {
|
||||||
|
let s = EditorState::new();
|
||||||
|
let panel = AIChatPlaceholder::from_editor(&s);
|
||||||
|
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
||||||
|
let p = Point2D::new(PAD + 8.0, toolbar_center_y());
|
||||||
|
|
||||||
|
assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::FocusInput));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hit_test_resolves_send_at_right() {
|
||||||
|
let mut s = EditorState::new();
|
||||||
|
seed_available_model(&mut s);
|
||||||
|
s.chat.input = "design a login page".into();
|
||||||
|
let panel = AIChatPlaceholder::from_editor(&s);
|
||||||
|
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
||||||
let send_x = AI_CHAT_WIDTH - PAD - 20.0;
|
let send_x = AI_CHAT_WIDTH - PAD - 20.0;
|
||||||
let p = Point2D::new(send_x, toolbar_center_y());
|
let p = Point2D::new(send_x, toolbar_center_y());
|
||||||
assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::Send));
|
assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::Send));
|
||||||
|
|
@ -91,7 +136,9 @@ fn hit_test_resolves_stop_at_right_while_streaming() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hit_test_resolves_bottom_toolbar_actions() {
|
fn hit_test_resolves_bottom_toolbar_actions() {
|
||||||
let s = EditorState::new();
|
let mut s = EditorState::new();
|
||||||
|
seed_available_model(&mut s);
|
||||||
|
s.chat.input = "design a login page".into();
|
||||||
let panel = AIChatPlaceholder::from_editor(&s);
|
let panel = AIChatPlaceholder::from_editor(&s);
|
||||||
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
||||||
let y = toolbar_center_y();
|
let y = toolbar_center_y();
|
||||||
|
|
@ -112,6 +159,7 @@ fn hit_test_resolves_bottom_toolbar_actions() {
|
||||||
#[test]
|
#[test]
|
||||||
fn hit_test_resolves_model_search_clear_button() {
|
fn hit_test_resolves_model_search_clear_button() {
|
||||||
let mut s = EditorState::new();
|
let mut s = EditorState::new();
|
||||||
|
seed_available_model(&mut s);
|
||||||
s.editor_ui.chat_model_picker_open = true;
|
s.editor_ui.chat_model_picker_open = true;
|
||||||
s.editor_ui.chat_model_picker_search = "231".into();
|
s.editor_ui.chat_model_picker_search = "231".into();
|
||||||
let panel = AIChatPlaceholder::from_editor(&s);
|
let panel = AIChatPlaceholder::from_editor(&s);
|
||||||
|
|
@ -159,7 +207,8 @@ fn hit_test_resolves_attachment_chip_at_painted_position() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hit_test_resolves_first_example_when_empty() {
|
fn hit_test_resolves_first_example_when_empty() {
|
||||||
let s = EditorState::new(); // chat empty by default
|
let mut s = EditorState::new(); // chat empty by default
|
||||||
|
seed_available_model(&mut s);
|
||||||
let panel = AIChatPlaceholder::from_editor(&s);
|
let panel = AIChatPlaceholder::from_editor(&s);
|
||||||
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
||||||
// First example card: top-left of grid.
|
// First example card: top-left of grid.
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,14 @@ pub(crate) fn paint_examples(
|
||||||
hint_label: &str,
|
hint_label: &str,
|
||||||
tip_label: &str,
|
tip_label: &str,
|
||||||
examples: &[ExampleCard; 4],
|
examples: &[ExampleCard; 4],
|
||||||
|
disabled: bool,
|
||||||
) {
|
) {
|
||||||
|
let opacity = if disabled { 0.6 } else { 1.0 };
|
||||||
let hint = TextLayout::single_run(
|
let hint = TextLayout::single_run(
|
||||||
hint_label,
|
hint_label,
|
||||||
"system-ui",
|
"system-ui",
|
||||||
12.0,
|
12.0,
|
||||||
to_jian_color(theme.muted_foreground),
|
to_jian_color(with_alpha(theme.muted_foreground, opacity)),
|
||||||
Point2D::new(0.0, 0.0),
|
Point2D::new(0.0, 0.0),
|
||||||
);
|
);
|
||||||
let hint_y = rect.origin.y + HEADER_HEIGHT + 16.0;
|
let hint_y = rect.origin.y + HEADER_HEIGHT + 16.0;
|
||||||
|
|
@ -98,17 +100,20 @@ pub(crate) fn paint_examples(
|
||||||
Point2D::new(rect.origin.x + (rect.size.x - hint_w) / 2.0, hint_y),
|
Point2D::new(rect.origin.x + (rect.size.x - hint_w) / 2.0, hint_y),
|
||||||
);
|
);
|
||||||
|
|
||||||
let card_bg = with_alpha(theme.muted, 0.3);
|
let card_bg = with_alpha(theme.muted, 0.3 * opacity);
|
||||||
|
let card_border = with_alpha(theme.border, opacity);
|
||||||
|
let title_color = with_alpha(theme.foreground, opacity);
|
||||||
|
let subtitle_color = with_alpha(theme.muted_foreground, opacity);
|
||||||
for (card, ex) in example_card_rects(rect).iter().zip(examples.iter()) {
|
for (card, ex) in example_card_rects(rect).iter().zip(examples.iter()) {
|
||||||
cx.backend.fill_round_rect(*card, 8.0, card_bg);
|
cx.backend.fill_round_rect(*card, 8.0, card_bg);
|
||||||
cx.backend.stroke_round_rect(*card, 8.0, theme.border, 1.0);
|
cx.backend.stroke_round_rect(*card, 8.0, card_border, 1.0);
|
||||||
cx.backend.save();
|
cx.backend.save();
|
||||||
cx.backend.clip_rect(*card);
|
cx.backend.clip_rect(*card);
|
||||||
let emoji_layout = TextLayout::single_run(
|
let emoji_layout = TextLayout::single_run(
|
||||||
ex.emoji,
|
ex.emoji,
|
||||||
"system-ui",
|
"system-ui",
|
||||||
14.0,
|
14.0,
|
||||||
to_jian_color(theme.foreground),
|
to_jian_color(title_color),
|
||||||
Point2D::new(0.0, 0.0),
|
Point2D::new(0.0, 0.0),
|
||||||
);
|
);
|
||||||
cx.backend.draw_text(
|
cx.backend.draw_text(
|
||||||
|
|
@ -122,7 +127,7 @@ pub(crate) fn paint_examples(
|
||||||
&ex.title,
|
&ex.title,
|
||||||
"system-ui",
|
"system-ui",
|
||||||
12.0,
|
12.0,
|
||||||
to_jian_color(theme.foreground),
|
to_jian_color(title_color),
|
||||||
Point2D::new(0.0, 0.0),
|
Point2D::new(0.0, 0.0),
|
||||||
);
|
);
|
||||||
cx.backend.draw_text(
|
cx.backend.draw_text(
|
||||||
|
|
@ -136,7 +141,7 @@ pub(crate) fn paint_examples(
|
||||||
&ex.subtitle,
|
&ex.subtitle,
|
||||||
"system-ui",
|
"system-ui",
|
||||||
11.0,
|
11.0,
|
||||||
to_jian_color(theme.muted_foreground),
|
to_jian_color(subtitle_color),
|
||||||
Point2D::new(0.0, 0.0),
|
Point2D::new(0.0, 0.0),
|
||||||
);
|
);
|
||||||
cx.backend.draw_text(
|
cx.backend.draw_text(
|
||||||
|
|
@ -243,6 +248,7 @@ mod tests {
|
||||||
"Start designing with AI",
|
"Start designing with AI",
|
||||||
"Tip",
|
"Tip",
|
||||||
&examples,
|
&examples,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ mod ai_chat_geometry;
|
||||||
mod chat_model_picker_caret;
|
mod chat_model_picker_caret;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod chat_model_picker_caret_tests;
|
mod chat_model_picker_caret_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod chat_send_tests;
|
||||||
mod click;
|
mod click;
|
||||||
mod color_picker_press;
|
mod color_picker_press;
|
||||||
mod component_browser_press;
|
mod component_browser_press;
|
||||||
|
|
|
||||||
36
crates/op-host-native/src/widget_host/chat_send_tests.rs
Normal file
36
crates/op-host-native/src/widget_host/chat_send_tests.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
use super::WidgetHostNative;
|
||||||
|
|
||||||
|
fn seed_available_model(host: &mut WidgetHostNative) {
|
||||||
|
host.editor_state_mut()
|
||||||
|
.chat
|
||||||
|
.available_models
|
||||||
|
.push(op_editor_core::ModelEntry::new(
|
||||||
|
op_editor_core::AgentProvider::CodexCli,
|
||||||
|
"gpt-5",
|
||||||
|
"GPT-5",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_send_ignores_chat_when_no_model_is_available() {
|
||||||
|
let mut host = WidgetHostNative::new();
|
||||||
|
host.editor_state_mut().chat.input = "design a login page".into();
|
||||||
|
|
||||||
|
assert!(!host.apply_send());
|
||||||
|
assert_eq!(host.editor_state().chat.input, "design a login page");
|
||||||
|
assert!(host.editor_state().chat.messages.is_empty());
|
||||||
|
assert!(host.editor_state().chat.pending_send.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_send_queues_chat_when_model_is_available() {
|
||||||
|
let mut host = WidgetHostNative::new();
|
||||||
|
seed_available_model(&mut host);
|
||||||
|
host.editor_state_mut().chat.input = "design a login page".into();
|
||||||
|
|
||||||
|
assert!(host.apply_send());
|
||||||
|
assert_eq!(
|
||||||
|
host.editor_state().chat.pending_send.as_deref(),
|
||||||
|
Some("design a login page")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -645,6 +645,9 @@ impl WidgetHostNative {
|
||||||
self.commit_property_focus_if_any();
|
self.commit_property_focus_if_any();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if self.editor_state.chat.available_models.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// `begin_send` itself gates on (text OR staged attachments) —
|
// `begin_send` itself gates on (text OR staged attachments) —
|
||||||
// an attachment-only turn is valid, so don't short-circuit on
|
// an attachment-only turn is valid, so don't short-circuit on
|
||||||
// empty text here.
|
// empty text here.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue