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,
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
let t = |key: &'static str| op_i18n::translate(locale, key).to_string();
|
||||
[
|
||||
|
|
@ -72,45 +69,30 @@ pub struct AIChatPlaceholder<'a> {
|
|||
pub id: WidgetId,
|
||||
pub theme: Theme,
|
||||
pub state: &'a ChatState,
|
||||
/// Host frame timestamp in ms; drives caret blink.
|
||||
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_start_with_ai: String,
|
||||
pub label_input_placeholder: String,
|
||||
/// Empty-state tip line below the example cards.
|
||||
pub label_tip_select_elements: String,
|
||||
/// Chip label shown when no model is selected / discovered yet
|
||||
/// (`ai.noModelsConnected`).
|
||||
pub label_no_models: String,
|
||||
/// Number of currently selected canvas nodes, shown in the
|
||||
/// bottom toolbar like the TS panel.
|
||||
pub(crate) selected_count: usize,
|
||||
/// 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`.
|
||||
/// Whether the model-picker dropdown is open.
|
||||
pub model_picker_open: bool,
|
||||
/// Vertical scroll offset of the open model-picker dropdown, in
|
||||
/// px (`Document.ui.chat_model_picker_scroll`).
|
||||
/// Vertical scroll offset of the open model-picker dropdown.
|
||||
pub model_picker_scroll: f32,
|
||||
/// Index into `state.available_models` of the picker row under
|
||||
/// the cursor (`Document.ui.chat_model_picker_hover`).
|
||||
/// Index into `state.available_models` of the picker row under the cursor.
|
||||
pub model_picker_hover: Option<usize>,
|
||||
/// Live model-picker search query
|
||||
/// (`Document.ui.chat_model_picker_search`).
|
||||
/// Live model-picker search query.
|
||||
pub model_picker_search: String,
|
||||
/// Byte caret for the model-picker search query.
|
||||
pub model_picker_caret: Option<usize>,
|
||||
/// Last focus / edit timestamp for the model-picker search caret.
|
||||
pub model_picker_caret_anchor_ms: u64,
|
||||
/// Localised empty-state example cards — resolved at construction
|
||||
/// time so the grid reflows when the user flips the Globe icon.
|
||||
/// Localised empty-state example cards.
|
||||
pub(crate) examples: [ExampleCard; 4],
|
||||
/// Active UI locale — threaded into the transcript layout /
|
||||
/// hit-test so the thinking / tool-call headers translate.
|
||||
/// Active UI locale.
|
||||
pub(crate) locale: op_editor_core::Locale,
|
||||
}
|
||||
|
||||
|
|
@ -119,8 +101,6 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
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 {
|
||||
let ui = &state.editor_ui;
|
||||
Self {
|
||||
|
|
@ -226,6 +206,7 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
if self.state.collapsed {
|
||||
return Some(AIChatHit::ToggleCollapse);
|
||||
}
|
||||
let can_use_model = !self.state.available_models.is_empty();
|
||||
// Expanded: chevron-down at top-left toggles collapse.
|
||||
let chevron_rect = Rect {
|
||||
origin: rect.origin,
|
||||
|
|
@ -265,7 +246,7 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
// open it behaves modally: a row click selects, any other
|
||||
// click dismisses it. Hit-tested before the input so a row
|
||||
// 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);
|
||||
if crate::widgets::ai_chat_model_picker::search_clear_hit(
|
||||
picker,
|
||||
|
|
@ -311,7 +292,11 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
// send icon buttons on the right.
|
||||
if point.y >= toolbar_top {
|
||||
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 attach_x = send_x - 32.0;
|
||||
|
|
@ -321,8 +306,13 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
if point.x >= send_x {
|
||||
return Some(if self.is_streaming() {
|
||||
AIChatHit::Stop
|
||||
} else {
|
||||
} else if can_use_model
|
||||
&& (!self.state.input.trim().is_empty()
|
||||
|| !self.state.pending_attachments.is_empty())
|
||||
{
|
||||
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).
|
||||
for (card, ex) in example_card_rects(rect).iter().zip(self.examples.iter()) {
|
||||
if rect_contains(*card, point) {
|
||||
|
|
@ -449,6 +439,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
}
|
||||
|
||||
paint_panel_surface(cx, &self.theme, rect);
|
||||
let can_use_model = !self.state.available_models.is_empty();
|
||||
let input_h = self.input_height();
|
||||
let sep_y = rect.origin.y + rect.size.y - input_h;
|
||||
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_tip_select_elements,
|
||||
&self.examples,
|
||||
!can_use_model || self.is_streaming(),
|
||||
);
|
||||
} else {
|
||||
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
|
||||
// both (TS parity: an attachment-only message is valid).
|
||||
let send_active =
|
||||
!self.state.input.trim().is_empty() || !self.state.pending_attachments.is_empty();
|
||||
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 {
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
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]
|
||||
fn hit_test_resolves_input_focus() {
|
||||
let s = EditorState::new();
|
||||
|
|
@ -66,10 +76,45 @@ fn hit_test_resolves_input_focus() {
|
|||
}
|
||||
|
||||
#[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 panel = AIChatPlaceholder::from_editor(&s);
|
||||
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 p = Point2D::new(send_x, toolbar_center_y());
|
||||
assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::Send));
|
||||
|
|
@ -91,7 +136,9 @@ fn hit_test_resolves_stop_at_right_while_streaming() {
|
|||
|
||||
#[test]
|
||||
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 rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
||||
let y = toolbar_center_y();
|
||||
|
|
@ -112,6 +159,7 @@ fn hit_test_resolves_bottom_toolbar_actions() {
|
|||
#[test]
|
||||
fn hit_test_resolves_model_search_clear_button() {
|
||||
let mut s = EditorState::new();
|
||||
seed_available_model(&mut s);
|
||||
s.editor_ui.chat_model_picker_open = true;
|
||||
s.editor_ui.chat_model_picker_search = "231".into();
|
||||
let panel = AIChatPlaceholder::from_editor(&s);
|
||||
|
|
@ -159,7 +207,8 @@ fn hit_test_resolves_attachment_chip_at_painted_position() {
|
|||
|
||||
#[test]
|
||||
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 rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
||||
// First example card: top-left of grid.
|
||||
|
|
|
|||
|
|
@ -83,12 +83,14 @@ pub(crate) fn paint_examples(
|
|||
hint_label: &str,
|
||||
tip_label: &str,
|
||||
examples: &[ExampleCard; 4],
|
||||
disabled: bool,
|
||||
) {
|
||||
let opacity = if disabled { 0.6 } else { 1.0 };
|
||||
let hint = TextLayout::single_run(
|
||||
hint_label,
|
||||
"system-ui",
|
||||
12.0,
|
||||
to_jian_color(theme.muted_foreground),
|
||||
to_jian_color(with_alpha(theme.muted_foreground, opacity)),
|
||||
Point2D::new(0.0, 0.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),
|
||||
);
|
||||
|
||||
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()) {
|
||||
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.clip_rect(*card);
|
||||
let emoji_layout = TextLayout::single_run(
|
||||
ex.emoji,
|
||||
"system-ui",
|
||||
14.0,
|
||||
to_jian_color(theme.foreground),
|
||||
to_jian_color(title_color),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend.draw_text(
|
||||
|
|
@ -122,7 +127,7 @@ pub(crate) fn paint_examples(
|
|||
&ex.title,
|
||||
"system-ui",
|
||||
12.0,
|
||||
to_jian_color(theme.foreground),
|
||||
to_jian_color(title_color),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend.draw_text(
|
||||
|
|
@ -136,7 +141,7 @@ pub(crate) fn paint_examples(
|
|||
&ex.subtitle,
|
||||
"system-ui",
|
||||
11.0,
|
||||
to_jian_color(theme.muted_foreground),
|
||||
to_jian_color(subtitle_color),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend.draw_text(
|
||||
|
|
@ -243,6 +248,7 @@ mod tests {
|
|||
"Start designing with AI",
|
||||
"Tip",
|
||||
&examples,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ mod ai_chat_geometry;
|
|||
mod chat_model_picker_caret;
|
||||
#[cfg(test)]
|
||||
mod chat_model_picker_caret_tests;
|
||||
#[cfg(test)]
|
||||
mod chat_send_tests;
|
||||
mod click;
|
||||
mod color_picker_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();
|
||||
return true;
|
||||
}
|
||||
if self.editor_state.chat.available_models.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// `begin_send` itself gates on (text OR staged attachments) —
|
||||
// an attachment-only turn is valid, so don't short-circuit on
|
||||
// empty text here.
|
||||
|
|
|
|||
Loading…
Reference in a new issue