diff --git a/crates/op-editor-core/src/chat.rs b/crates/op-editor-core/src/chat.rs index 36cd0c69d..8df87d1e4 100644 --- a/crates/op-editor-core/src/chat.rs +++ b/crates/op-editor-core/src/chat.rs @@ -237,8 +237,18 @@ pub struct ChatState { /// Set by `begin_send` to the just-sent user text; the desktop /// event loop drains this each frame. `None` = idle. pub pending_send: Option, - /// Models the user can pick in the chat panel's model dropdown. - /// Empty until the desktop host discovers them from connected CLIs. + /// Full model catalog discovered from every *installed* CLI, + /// before the connected-providers filter. The desktop host fills + /// this from `model_discovery`; [`rebuild_available_models`] then + /// derives [`available_models`] from it. + /// + /// [`rebuild_available_models`]: ChatState::rebuild_available_models + /// [`available_models`]: ChatState::available_models + pub discovered_models: Vec, + /// Models the user can pick in the chat panel's model dropdown — + /// `discovered_models` filtered to the providers the user has + /// *connected* in Settings → Agents. Empty until the host runs + /// discovery and the user connects at least one agent. pub available_models: Vec, /// Index into `available_models` of the active model. pub selected_model: usize, @@ -279,6 +289,7 @@ impl Default for ChatState { collapsed: false, caret_anchor_ms: 0, pending_send: None, + discovered_models: Vec::new(), available_models: Vec::new(), selected_model: 0, thinking_mode: ThinkingMode::Adaptive, @@ -296,6 +307,40 @@ impl ChatState { self.available_models.get(self.selected_model) } + /// Recompute [`available_models`] = [`discovered_models`] filtered + /// to the providers the user has connected (`connected` is indexed + /// by [`AgentProvider::ALL`]). The previously-selected model is + /// preserved by identity when it survives the filter, otherwise + /// `selected_model` falls back to `0`. + /// + /// Called by the host after model discovery completes and after + /// every connect / disconnect toggle, so the picker only ever + /// lists models the user can actually reach. + /// + /// [`available_models`]: ChatState::available_models + /// [`discovered_models`]: ChatState::discovered_models + pub fn rebuild_available_models(&mut self, connected: &[bool; 5]) { + let prev = self.available_models.get(self.selected_model).cloned(); + self.available_models = self + .discovered_models + .iter() + .filter(|m| { + AgentProvider::ALL + .iter() + .position(|p| *p == m.provider) + .is_some_and(|i| connected[i]) + }) + .cloned() + .collect(); + self.selected_model = prev + .and_then(|p| { + self.available_models + .iter() + .position(|m| m.provider == p.provider && m.value == p.value) + }) + .unwrap_or(0); + } + /// Append the focused input as a new user message + a stub /// assistant echo, then clear the buffer. Offline fallback used by /// hosts with no real `ChatProvider` wired. @@ -725,4 +770,56 @@ mod tests { ChatAnchor::BottomRight ); } + + #[test] + fn rebuild_available_models_keeps_only_connected_providers() { + let mut chat = ChatState { + discovered_models: vec![ + ModelEntry::new(AgentProvider::ClaudeCode, "opus", "Opus"), + ModelEntry::new(AgentProvider::ClaudeCode, "sonnet", "Sonnet"), + ModelEntry::new(AgentProvider::CodexCli, "gpt-5.5", "GPT-5.5"), + ModelEntry::new(AgentProvider::OpenCode, "oc/x", "oc/x"), + ], + ..Default::default() + }; + // Only Claude Code (index 0 of AgentProvider::ALL) connected. + let mut connected = [false; 5]; + connected[0] = true; + chat.rebuild_available_models(&connected); + assert_eq!(chat.available_models.len(), 2); + assert!(chat + .available_models + .iter() + .all(|m| m.provider == AgentProvider::ClaudeCode)); + } + + #[test] + fn rebuild_available_models_preserves_selection_by_identity() { + let mut chat = ChatState { + discovered_models: vec![ + ModelEntry::new(AgentProvider::ClaudeCode, "opus", "Opus"), + ModelEntry::new(AgentProvider::CodexCli, "gpt-5.5", "GPT-5.5"), + ], + ..Default::default() + }; + let mut connected = [false; 5]; + connected[0] = true; // Claude + connected[1] = true; // Codex + chat.rebuild_available_models(&connected); + // Select Codex's GPT-5.5 (index 1). + chat.selected_model = 1; + // Disconnecting Claude drops index 0 — the selection must + // follow GPT-5.5 to its new index rather than dangle. + connected[0] = false; + chat.rebuild_available_models(&connected); + assert_eq!(chat.available_models.len(), 1); + assert_eq!(chat.selected_model, 0); + assert_eq!(chat.available_models[0].value, "gpt-5.5"); + // Disconnecting the last provider empties the list and the + // selection clamps back to 0. + connected[1] = false; + chat.rebuild_available_models(&connected); + assert!(chat.available_models.is_empty()); + assert_eq!(chat.selected_model, 0); + } } diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index 92ad3db34..16d227463 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -526,6 +526,13 @@ pub struct EditorUiState { // --- AI chat model picker -------------------------------------- /// AI chat model-picker dropdown open. pub chat_model_picker_open: bool, + /// Vertical scroll offset of the model-picker dropdown, in px. + /// Non-zero only when the connected catalog is taller than the + /// picker's capped height; the host clamps it on wheel input. + pub chat_model_picker_scroll: f32, + /// Index into `chat.available_models` of the model row the cursor + /// is over, or `None`. Drives the picker's hover-row tint. + pub chat_model_picker_hover: Option, /// Index into `AgentProvider::ALL` of the agent driving the chat. pub chat_selected_agent: usize, @@ -661,6 +668,8 @@ impl Default for EditorUiState { shape_picker_hover: None, shape_tool: Tool::Rect, chat_model_picker_open: false, + chat_model_picker_scroll: 0.0, + chat_model_picker_hover: None, chat_selected_agent: 0, align_toolbar_hover: None, property_tab: PropertyTab::Design, diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index b9cfe4306..a77040f86 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -633,6 +633,19 @@ impl EditorState { } } self.editor_ui.chat_model_picker_open = false; + self.editor_ui.chat_model_picker_scroll = 0.0; + self.editor_ui.chat_model_picker_hover = None; + } + + /// Recompute the chat model-picker's `available_models` from the + /// discovered catalog filtered by the providers connected in + /// Settings → Agents. Thin wrapper over + /// [`ChatState::rebuild_available_models`] that reads the + /// connected mask off `editor_ui.agent_settings`. Hosts call this + /// after discovery finishes and after every connect toggle. + pub fn rebuild_chat_models(&mut self) { + let connected = self.editor_ui.agent_settings.connected; + self.chat.rebuild_available_models(&connected); } /// Light invariant check — Err on first violation: out-of-range diff --git a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs index 174e360b9..aff6cfe30 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs @@ -20,6 +20,22 @@ pub const MODEL_GROUP_H: f32 = 22.0; pub const MODEL_ROW_H: f32 = 28.0; /// Vertical padding inside the dropdown card (top + bottom each). pub const MODEL_PICKER_PAD_Y: f32 = 6.0; +/// Hard cap on the dropdown's painted height. A connected catalog +/// taller than this (e.g. OpenCode's 75+ models) scrolls inside the +/// card instead of growing off the top of the screen. +pub const MODEL_PICKER_MAX_H: f32 = 320.0; + +/// Painted height of the dropdown for `models` — the content height +/// clamped to [`MODEL_PICKER_MAX_H`]. +pub fn picker_view_height(models: &[ModelEntry]) -> f32 { + picker_content_height(models).min(MODEL_PICKER_MAX_H) +} + +/// Largest valid scroll offset for `models` — `0` when the content +/// already fits inside [`MODEL_PICKER_MAX_H`]. +pub fn max_picker_scroll(models: &[ModelEntry]) -> f32 { + (picker_content_height(models) - MODEL_PICKER_MAX_H).max(0.0) +} /// One laid-out row in the dropdown. enum Row { @@ -62,7 +78,9 @@ pub fn picker_content_height(models: &[ModelEntry]) -> f32 { /// Map a click inside the dropdown `rect` to the index of the /// model row under it. `None` for a click on a header / padding. -pub fn model_at(rect: Rect, point: Point2D, models: &[ModelEntry]) -> Option { +/// `scroll` is the dropdown's vertical scroll offset in px — paint +/// and hit-test share it so a scrolled row resolves correctly. +pub fn model_at(rect: Rect, point: Point2D, models: &[ModelEntry], scroll: f32) -> Option { if point.x < rect.origin.x || point.x > rect.origin.x + rect.size.x || point.y < rect.origin.y @@ -71,9 +89,16 @@ pub fn model_at(rect: Rect, point: Point2D, models: &[ModelEntry]) -> Option= y && point.y < y + h { + if point.y >= y + && point.y < y + h + && point.y >= rect.origin.y + && point.y <= rect.origin.y + rect.size.y + { hit = Some(*idx); } } @@ -82,20 +107,30 @@ pub fn model_at(rect: Rect, point: Point2D, models: &[ModelEntry]) -> Option, theme: &Theme, rect: Rect, models: &[ModelEntry], selected: usize, + scroll: f32, + hover: Option, ) { - // Card background + border. + // Card background + border — painted unscrolled so the frame + // stays put while the rows scroll inside it. cx.backend.fill_round_rect(rect, 10.0, theme.popover); cx.backend.stroke_round_rect(rect, 10.0, theme.border, 1.0); let row_left = rect.origin.x + 12.0; let row_w = rect.size.x - 12.0; + // Clip to the card and shift by `-scroll` so off-card rows are + // trimmed and the visible band tracks the scroll offset. + cx.backend.save(); + cx.backend.clip_rect(rect); + cx.backend.translate(Point2D::new(0.0, -scroll)); walk_rows(models, rect.origin.y, |row, y, h| match row { Row::Header(provider) => { let logo_y = y + (h - 12.0) / 2.0; @@ -118,6 +153,19 @@ pub fn paint_model_picker( } Row::Model(idx) => { let is_selected = *idx == selected; + let is_hovered = hover == Some(*idx); + // Hover wash on any non-selected row the cursor is over; + // the selected row keeps its own `muted` fill below. + if is_hovered && !is_selected { + cx.backend.fill_round_rect( + Rect { + origin: Point2D::new(rect.origin.x + 4.0, y + 1.0), + size: Point2D::new(rect.size.x - 8.0, h - 2.0), + }, + 6.0, + theme.button_hover, + ); + } if is_selected { cx.backend.fill_round_rect( Rect { @@ -156,7 +204,30 @@ pub fn paint_model_picker( .draw_text(&label, Point2D::new(row_left + 22.0, y + h / 2.0 + 4.0)); } }); + cx.backend.restore(); let _ = row_w; + + // Scrollbar thumb — drawn after `restore()` so it sits in + // unscrolled card space. Shown only when the content overflows. + let content_h = picker_content_height(models); + let view_h = rect.size.y; + if content_h > view_h + 0.5 { + let track_h = view_h - 8.0; + let thumb_h = (track_h * view_h / content_h).max(24.0); + let max_scroll = (content_h - view_h).max(0.0); + let t = if max_scroll > 0.0 { + (scroll / max_scroll).clamp(0.0, 1.0) + } else { + 0.0 + }; + let thumb_y = rect.origin.y + 4.0 + t * (track_h - thumb_h); + let thumb = Rect { + origin: Point2D::new(rect.origin.x + rect.size.x - 6.0, thumb_y), + size: Point2D::new(3.0, thumb_h), + }; + cx.backend + .fill_round_rect(thumb, 1.5, theme.muted_foreground); + } } /// Paint a provider's brand logo into a `size × size` square. @@ -231,11 +302,38 @@ mod tests { // First model row sits below the first group header. let first_row_y = MODEL_PICKER_PAD_Y + MODEL_GROUP_H + MODEL_ROW_H / 2.0; assert_eq!( - model_at(rect, Point2D::new(100.0, first_row_y), &models), + model_at(rect, Point2D::new(100.0, first_row_y), &models, 0.0), Some(0) ); // A click on the header band resolves to nothing. let header_y = MODEL_PICKER_PAD_Y + MODEL_GROUP_H / 2.0; - assert_eq!(model_at(rect, Point2D::new(100.0, header_y), &models), None); + assert_eq!( + model_at(rect, Point2D::new(100.0, header_y), &models, 0.0), + None + ); + } + + #[test] + fn model_at_honors_scroll_offset() { + // A tall catalog (one group, many rows) clamped to the cap; + // with the content scrolled down, the row under a fixed + // cursor point shifts to a later index. + let models: Vec = (0..40) + .map(|i| entry(AgentProvider::OpenCode, &format!("m{i}"))) + .collect(); + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(200.0, picker_view_height(&models)), + }; + let probe = Point2D::new( + 100.0, + MODEL_PICKER_PAD_Y + MODEL_GROUP_H + MODEL_ROW_H / 2.0, + ); + let unscrolled = model_at(rect, probe, &models, 0.0); + let scrolled = model_at(rect, probe, &models, MODEL_ROW_H * 3.0); + assert_eq!(unscrolled, Some(0)); + assert_eq!(scrolled, Some(3)); + // The catalog overflows the cap, so scrolling is possible. + assert!(max_picker_scroll(&models) > 0.0); } } 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 3598744a3..b68675fa3 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -166,6 +166,12 @@ pub struct AIChatPlaceholder<'a> { /// `state.available_models`; the active row is /// `state.selected_model`. pub model_picker_open: bool, + /// Vertical scroll offset of the open model-picker dropdown, in + /// px (`Document.ui.chat_model_picker_scroll`). + pub model_picker_scroll: f32, + /// Index into `state.available_models` of the picker row under + /// the cursor (`Document.ui.chat_model_picker_hover`). + pub model_picker_hover: Option, /// Localised empty-state example cards — resolved at construction /// time so the grid reflows when the user flips the Globe icon. pub(crate) examples: [ExampleCard; 4], @@ -194,6 +200,8 @@ impl<'a> AIChatPlaceholder<'a> { label_tip_select_elements: translate(ui, "ai.tipSelectElements").to_string(), label_no_models: translate(ui, "ai.noModelsConnected").to_string(), model_picker_open: ui.chat_model_picker_open, + model_picker_scroll: ui.chat_model_picker_scroll, + model_picker_hover: ui.chat_model_picker_hover, examples: example_cards(ui.locale), locale: ui.locale, } @@ -229,11 +237,12 @@ impl<'a> AIChatPlaceholder<'a> { /// Bounds of the model-picker dropdown — anchored just above /// the bottom toolbar (the chip), growing upward over the - /// message list. `input_rect` is the panel's input box. + /// message list. `input_rect` is the panel's input box. The + /// height is capped at `MODEL_PICKER_MAX_H`; a taller catalog + /// scrolls inside the card rather than overflowing the screen. fn model_picker_rect(&self, rect: Rect, input_rect: Rect) -> Rect { - let height = crate::widgets::ai_chat_model_picker::picker_content_height( - &self.state.available_models, - ); + let height = + crate::widgets::ai_chat_model_picker::picker_view_height(&self.state.available_models); let toolbar_top = input_rect.origin.y + INPUT_AREA_HEIGHT + self.attachment_row_h() + CONTROLS_ROW_HEIGHT; let bottom = toolbar_top - 4.0; @@ -243,6 +252,25 @@ impl<'a> AIChatPlaceholder<'a> { } } + /// Public bounds of the open model-picker dropdown — `None` when + /// the picker is closed. The host uses this to route wheel + + /// cursor-move input into the picker (scroll + hover) without + /// re-deriving the panel's internal input-block geometry. + pub fn model_picker_bounds(&self, rect: Rect) -> Option { + if !self.model_picker_open { + return None; + } + let input_h = self.input_height(); + let input_rect = Rect { + origin: Point2D::new( + rect.origin.x + PAD, + rect.origin.y + rect.size.y - input_h + 1.0, + ), + size: Point2D::new(rect.size.x - PAD * 2.0, input_h), + }; + Some(self.model_picker_rect(rect, input_rect)) + } + pub fn hit_test(&self, rect: Rect, point: Point2D) -> Option { if !rect_contains(rect, point) { return None; @@ -285,6 +313,7 @@ impl<'a> AIChatPlaceholder<'a> { picker, point, &self.state.available_models, + self.model_picker_scroll, ) { return Some(AIChatHit::SelectModel(idx)); } @@ -525,13 +554,23 @@ impl<'a> Widget for AIChatPlaceholder<'a> { self.theme.border, ); - // Textarea region — borderless, single line of placeholder / - // typed text, 14 px to mirror the TS app's textarea style. + // Textarea region — borderless, 14 px to mirror the TS app's + // textarea style. Long input wraps across up to `MAX_LINES` + // visible rows; beyond that the view anchors to the bottom + // (textarea scroll-to-bottom) so the caret line stays in + // sight. `clip_rect` keeps an over-long line from bleeding + // past the panel edge. let input_rect = Rect { origin: Point2D::new(rect.origin.x + PAD, sep_y + 1.0), size: Point2D::new(rect.size.x - PAD * 2.0, INPUT_AREA_HEIGHT), }; - let (text, color) = if self.state.input.is_empty() { + /// Baseline-to-baseline gap for the wrapped input. + const LINE_H: f32 = 18.0; + /// First line's baseline, relative to `input_rect` top. + const FIRST_BASELINE: f32 = 16.0; + const MAX_LINES: usize = 3; + let is_placeholder = self.state.input.is_empty(); + let (text, color) = if is_placeholder { ( self.label_input_placeholder.as_str(), self.theme.muted_foreground, @@ -539,26 +578,54 @@ impl<'a> Widget for AIChatPlaceholder<'a> { } else { (self.state.input.as_str(), self.theme.foreground) }; - let input_label = TextLayout::single_run( + let wrapped = crate::widgets::canvas_viewport_overlay::wrap_text( + cx.backend, text, - "system-ui", 14.0, - to_jian_color(color), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &input_label, - Point2D::new(input_rect.origin.x, input_rect.origin.y + 22.0), + input_rect.size.x, + 400, ); + // Anchor to the bottom — keep the last `MAX_LINES` rows, the + // ones nearest the (end-anchored) caret. + let start = wrapped.len().saturating_sub(MAX_LINES); + let visible = &wrapped[start..]; + cx.backend.save(); + cx.backend.clip_rect(input_rect); + for (i, line) in visible.iter().enumerate() { + let label = TextLayout::single_run( + line, + "system-ui", + 14.0, + to_jian_color(color), + Point2D::new(0.0, 0.0), + ); + let baseline = input_rect.origin.y + FIRST_BASELINE + i as f32 * LINE_H; + cx.backend + .draw_text(&label, Point2D::new(input_rect.origin.x, baseline)); + } + cx.backend.restore(); let caret_visible = self.state.focused && jian_core::anim::blink_visible(self.now_ms, self.state.caret_anchor_ms, 500); if caret_visible { - let text_w = cx.backend.measure_text(&self.state.input, 14.0); - let caret_x = input_rect.origin.x + text_w; + // The caret tracks the input's end. On an empty buffer it + // sits at the start of line 0 (the placeholder text is not + // part of the buffer); otherwise after the last wrapped + // row's glyphs. + let (caret_x, caret_line) = if is_placeholder { + (input_rect.origin.x, 0usize) + } else { + let last = visible.last().map(String::as_str).unwrap_or(""); + ( + input_rect.origin.x + cx.backend.measure_text(last, 14.0), + visible.len().saturating_sub(1), + ) + }; + let caret_top = + input_rect.origin.y + FIRST_BASELINE + caret_line as f32 * LINE_H - 13.0; cx.backend.fill_rect( Rect { - origin: Point2D::new(caret_x, input_rect.origin.y + 8.0), - size: Point2D::new(1.5, 18.0), + origin: Point2D::new(caret_x, caret_top), + size: Point2D::new(1.5, 17.0), }, self.theme.foreground, ); @@ -684,6 +751,8 @@ impl<'a> Widget for AIChatPlaceholder<'a> { picker, &self.state.available_models, self.state.selected_model, + self.model_picker_scroll, + self.model_picker_hover, ); } } diff --git a/crates/op-host-desktop/src/model_discovery.rs b/crates/op-host-desktop/src/model_discovery.rs index ac5b56789..3390b0ecf 100644 --- a/crates/op-host-desktop/src/model_discovery.rs +++ b/crates/op-host-desktop/src/model_discovery.rs @@ -56,10 +56,13 @@ impl ModelProbe { Ok(models) => { // The discovery worker emits shell-core `ModelEntry`s; // translate each into the op-editor-core type the - // host's `EditorState.chat` carries. - let chat = &mut host.editor_state_mut().chat; - chat.available_models = models.into_iter().map(model_entry_to_ec).collect(); - chat.selected_model = 0; + // host's `EditorState.chat` carries. The translated + // list is the *full* catalog (every installed CLI); + // `rebuild_chat_models` then narrows it to the + // providers the user has connected in Settings. + let es = host.editor_state_mut(); + es.chat.discovered_models = models.into_iter().map(model_entry_to_ec).collect(); + es.rebuild_chat_models(); host.mark_editor_state_dirty(); self.rx = None; true diff --git a/crates/op-host-desktop/src/settings_io.rs b/crates/op-host-desktop/src/settings_io.rs index 1714143d8..78c544a8e 100644 --- a/crates/op-host-desktop/src/settings_io.rs +++ b/crates/op-host-desktop/src/settings_io.rs @@ -35,6 +35,7 @@ pub struct Fingerprint { port: u16, cli: [bool; 6], images_adv: bool, + connected: [bool; 5], } pub fn fingerprint(state: &EditorState) -> Fingerprint { @@ -45,6 +46,7 @@ pub fn fingerprint(state: &EditorState) -> Fingerprint { port: eui.agent_settings.mcp_server.port, cli: eui.agent_settings.mcp_cli_enabled, images_adv: eui.agent_settings.images_advanced_open, + connected: eui.agent_settings.connected, } } @@ -71,6 +73,11 @@ struct SettingsPayload { mcp_cli_enabled: Option<[bool; 6]>, #[serde(default)] images_advanced_open: Option, + /// Per-provider connect state, indexed by `AgentProvider::ALL` + /// (Claude / Codex / OpenCode / Copilot / Gemini). Restored on + /// launch so the chat model picker survives a restart. + #[serde(default)] + connected: Option<[bool; 5]>, #[serde(default)] recent_files: Option>, } @@ -93,6 +100,7 @@ fn to_payload(state: &EditorState) -> SettingsPayload { mcp_port: Some(eui.agent_settings.mcp_server.port), mcp_cli_enabled: Some(eui.agent_settings.mcp_cli_enabled), images_advanced_open: Some(eui.agent_settings.images_advanced_open), + connected: Some(eui.agent_settings.connected), recent_files: Some( eui.recent_files .iter() @@ -127,6 +135,9 @@ fn apply_payload(state: &mut EditorState, payload: SettingsPayload) { if let Some(b) = payload.images_advanced_open { eui.agent_settings.images_advanced_open = b; } + if let Some(c) = payload.connected { + eui.agent_settings.connected = c; + } if let Some(list) = payload.recent_files { eui.recent_files = list .into_iter() @@ -137,6 +148,11 @@ fn apply_payload(state: &mut EditorState, payload: SettingsPayload) { }) .collect(); } + // Restored connect state changes which providers the chat model + // picker may list — re-derive it. `discovered_models` is still + // empty this early, so this is a no-op until discovery lands and + // `ModelProbe::poll_into` rebuilds again against the same mask. + state.rebuild_chat_models(); } /// Push `path` to the head of the recent-files list on the host's @@ -244,3 +260,36 @@ fn str_to_locale(s: &str) -> Option { _ => return None, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connected_state_round_trips_through_payload() { + // Connect Claude (0) + Gemini (4), leave the rest off. + let mut src = EditorState::new(); + src.editor_ui.agent_settings.connected = [true, false, false, false, true]; + // Serialize → JSON → deserialize, the real on-disk path. + let json = serde_json::to_string(&to_payload(&src)).unwrap(); + let payload: SettingsPayload = serde_json::from_str(&json).unwrap(); + let mut dst = EditorState::new(); + apply_payload(&mut dst, payload); + assert_eq!( + dst.editor_ui.agent_settings.connected, + [true, false, false, false, true] + ); + } + + #[test] + fn legacy_settings_without_connected_field_default_to_disconnected() { + // A settings.json written before the `connected` field + // existed must still load — the missing field defaults to + // all-disconnected rather than failing the parse. + let legacy = r#"{"version":1,"theme":"dark","locale":"en-US"}"#; + let payload: SettingsPayload = serde_json::from_str(legacy).unwrap(); + let mut dst = EditorState::new(); + apply_payload(&mut dst, payload); + assert_eq!(dst.editor_ui.agent_settings.connected, [false; 5]); + } +} diff --git a/crates/op-host-native/src/widget_host/click.rs b/crates/op-host-native/src/widget_host/click.rs index 00e8b38b6..c9779296b 100644 --- a/crates/op-host-native/src/widget_host/click.rs +++ b/crates/op-host-native/src/widget_host/click.rs @@ -165,6 +165,11 @@ impl WidgetHostNative { AIChatHit::ToggleModelPicker => { self.editor_state.editor_ui.chat_model_picker_open = !self.editor_state.editor_ui.chat_model_picker_open; + // Reopen the picker un-scrolled / un-hovered so + // a stale offset from a prior open never hides + // the top of the catalog. + self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; + self.editor_state.editor_ui.chat_model_picker_hover = None; self.mark_dirty(); return true; } @@ -213,6 +218,8 @@ impl WidgetHostNative { // model picker if it was open. let picker_was_open = self.editor_state.editor_ui.chat_model_picker_open; self.editor_state.editor_ui.chat_model_picker_open = false; + self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; + self.editor_state.editor_ui.chat_model_picker_hover = None; let was_focused = self.editor_state.chat.focused || picker_was_open; self.editor_state.chat.focused = false; self.mark_dirty(); diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index f54b8408c..f7c672349 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -76,6 +76,7 @@ impl WidgetHostNative { changed |= ui.locale_picker_hover.take().is_some(); changed |= ui.shape_picker_hover.take().is_some(); changed |= ui.align_toolbar_hover.take().is_some(); + changed |= ui.chat_model_picker_hover.take().is_some(); if let Some(menu) = ui.layer_context_menu.as_mut() { changed |= menu.hovered_row.take().is_some(); } diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index eb116930c..7f25d6226 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -248,6 +248,34 @@ impl WidgetHostNative { return true; } } + // Open chat model-picker — track the model row under the + // cursor so the dropdown paints a hover wash. `model_at` + // returns `None` off the rows (headers / padding / off the + // card), which clears any stale highlight. + if self.editor_state.editor_ui.chat_model_picker_open && !over_topmost { + use op_editor_ui::widgets::ai_chat_model_picker::model_at; + use op_editor_ui::widgets::AIChatPlaceholder; + let picker = self + .ai_chat_rect(self.last_viewport_w, self.last_viewport_h) + .and_then(|chat_rect| { + AIChatPlaceholder::from_editor_at(&self.editor_state, self.now_ms) + .model_picker_bounds(chat_rect) + }); + if let Some(picker) = picker { + let scroll = self.editor_state.editor_ui.chat_model_picker_scroll; + let new_hover = model_at( + picker, + Point2D::new(x, y), + &self.editor_state.chat.available_models, + scroll, + ); + if new_hover != self.editor_state.editor_ui.chat_model_picker_hover { + self.editor_state.editor_ui.chat_model_picker_hover = new_hover; + self.mark_dirty(); + return true; + } + } + } if let Some(drag) = self.rotate_drag { let cursor_angle = (y - drag.center_screen_y).atan2(x - drag.center_screen_x); let new_rotation = drag.start_rotation + (cursor_angle - drag.start_cursor_angle); @@ -466,7 +494,11 @@ impl WidgetHostNative { drag.last_x = x; drag.last_y = y; self.editor_state.viewport.pan(dx, dy); - self.mark_dirty(); + // No `mark_dirty()`: a canvas pan-drag only translates the + // viewport, not the document tree, so the cached + // `layout_scene` stays valid (re-solving taffy layout on + // every drag frame was the pan jank). `return true` still + // drives the repaint that re-applies the viewport. return true; } // Align toolbar hover sync — AFTER drag detection. Suppressed diff --git a/crates/op-host-native/src/widget_host/press_helpers.rs b/crates/op-host-native/src/widget_host/press_helpers.rs index b6c34534f..392605a3f 100644 --- a/crates/op-host-native/src/widget_host/press_helpers.rs +++ b/crates/op-host-native/src/widget_host/press_helpers.rs @@ -71,6 +71,10 @@ impl WidgetHostNative { .unwrap_or(0); let v = &mut self.editor_state.editor_ui.agent_settings.connected[idx]; *v = !*v; + // Connecting / disconnecting a provider changes which + // models the chat picker may list — re-derive it from + // the discovered catalog against the new mask. + self.editor_state.rebuild_chat_models(); } AgentSettingsHit::ToggleMcpServer => { let v = &mut self diff --git a/crates/op-host-native/src/widget_host/scroll.rs b/crates/op-host-native/src/widget_host/scroll.rs index 44d54fb24..09a74d856 100644 --- a/crates/op-host-native/src/widget_host/scroll.rs +++ b/crates/op-host-native/src/widget_host/scroll.rs @@ -24,6 +24,28 @@ impl WidgetHostNative { if self.over_topmost_panel(x, y, viewport_width, viewport_height) { return true; } + // Open chat model-picker — a wheel over its dropdown scrolls + // the model list instead of zooming the canvas. + if self.editor_state.editor_ui.chat_model_picker_open { + use op_editor_ui::widgets::ai_chat_model_picker::max_picker_scroll; + use op_editor_ui::widgets::AIChatPlaceholder; + let picker = self + .ai_chat_rect(viewport_width, viewport_height) + .and_then(|chat_rect| { + AIChatPlaceholder::from_editor_at(&self.editor_state, self.now_ms) + .model_picker_bounds(chat_rect) + }); + if let Some(picker) = picker { + if rect_contains(picker, Point2D::new(x, y)) { + let max = max_picker_scroll(&self.editor_state.chat.available_models); + let next = (self.editor_state.editor_ui.chat_model_picker_scroll - delta_y) + .clamp(0.0, max); + self.editor_state.editor_ui.chat_model_picker_scroll = next; + self.mark_dirty(); + return true; + } + } + } // Agent-settings modal owns wheel. if self.editor_state.editor_ui.agent_settings_open { use op_editor_ui::widgets::agent_settings_panel::AgentSettingsPanel; @@ -87,7 +109,12 @@ impl WidgetHostNative { let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_width, viewport_height); let cursor = Point2D::new(x - cx0, y - cy0); self.editor_state.viewport.zoom_at(cursor, delta_y); - self.mark_dirty(); + // No `mark_dirty()`: a zoom only changes the viewport + // transform, not the document tree, so the cached + // `layout_scene` stays valid — re-running the taffy layout + // solve + skia text measurement every wheel tick was the + // canvas-zoom jank. The `true` return still drives the + // repaint, which re-applies the new viewport transform. true } @@ -105,6 +132,28 @@ impl WidgetHostNative { if self.over_topmost_panel(x, y, viewport_width, viewport_height) { return true; } + // Open chat model-picker owns trackpad scroll over its + // dropdown, same as the wheel path. + if self.editor_state.editor_ui.chat_model_picker_open { + use op_editor_ui::widgets::ai_chat_model_picker::max_picker_scroll; + use op_editor_ui::widgets::AIChatPlaceholder; + let picker = self + .ai_chat_rect(viewport_width, viewport_height) + .and_then(|chat_rect| { + AIChatPlaceholder::from_editor_at(&self.editor_state, self.now_ms) + .model_picker_bounds(chat_rect) + }); + if let Some(picker) = picker { + if rect_contains(picker, Point2D::new(x, y)) { + let max = max_picker_scroll(&self.editor_state.chat.available_models); + let next = + (self.editor_state.editor_ui.chat_model_picker_scroll - dy).clamp(0.0, max); + self.editor_state.editor_ui.chat_model_picker_scroll = next; + self.mark_dirty(); + return true; + } + } + } // Agent-settings modal owns trackpad scroll same as wheel. if self.editor_state.editor_ui.agent_settings_open { use op_editor_ui::widgets::agent_settings_panel::AgentSettingsPanel; @@ -170,7 +219,9 @@ impl WidgetHostNative { return false; } self.editor_state.viewport.pan(dx, dy); - self.mark_dirty(); + // No `mark_dirty()`: a pan only translates the viewport, not + // the document tree — see the `apply_wheel` zoom branch. The + // `true` return drives the repaint. true } } diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index 7560e2fdc..fa60cb008 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -704,6 +704,8 @@ impl WidgetHost { .position(|x| *x == p) .unwrap_or(0); self.editor_state.editor_ui.agent_settings.connected[idx] ^= true; + // Re-derive the chat model picker for the new mask. + self.editor_state.rebuild_chat_models(); } AgentSettingsHit::ToggleMcpServer => { self.editor_state