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 8e101dcb..345eed68 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -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, - /// 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, /// 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 { ( 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 1e5f9e58..d5320666 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 @@ -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. diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs index 8489417d..f8d9d2f6 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs @@ -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!( diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index db777c9d..6f82ed36 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -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; diff --git a/crates/op-host-native/src/widget_host/chat_send_tests.rs b/crates/op-host-native/src/widget_host/chat_send_tests.rs new file mode 100644 index 00000000..b7267451 --- /dev/null +++ b/crates/op-host-native/src/widget_host/chat_send_tests.rs @@ -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") + ); +} diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index caf8a0d0..fa362034 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -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.