fix(ai): disable chat actions without models

This commit is contained in:
Fini 2026-05-31 19:52:08 +08:00
parent 1d2cc87d7c
commit e8be687ca4
6 changed files with 129 additions and 41 deletions

View file

@ -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 {
(

View file

@ -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.

View file

@ -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!(

View file

@ -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;

View 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")
);
}

View file

@ -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.