feat(chat): connected-agent model picker, input wrap + pan perf

Three chat-panel fixes that share `ai_chat_panel.rs` / `input.rs` /
`scroll.rs`, so they land together:

- Model picker scoped to connected agents: discovery still probes
  every installed CLI into `chat.discovered_models`, but the picker
  lists only providers the user connected (`rebuild_available_models`,
  re-run on connect-toggle + discovery). Connect state persists in
  settings.json. The dropdown is height-capped, scrolls (wheel /
  trackpad) with a thumb, and tints the hovered row.
- Chat input wraps: long input flows across up to 3 visible rows,
  clipped + bottom-anchored, instead of overflowing the panel edge.
- perf: a canvas pan / zoom no longer marks the layout scene dirty
  — it only moves the viewport transform, so re-running the taffy
  layout solve + skia text measurement every drag frame was pure
  waste. The repaint still re-applies the viewport.
This commit is contained in:
Kayshen-X 2026-05-22 11:40:43 +08:00
parent f1c45d6b9c
commit bb25ef512d
13 changed files with 471 additions and 36 deletions

View file

@ -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<String>,
/// 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<ModelEntry>,
/// 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<ModelEntry>,
/// 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);
}
}

View file

@ -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<usize>,
/// 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,

View file

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

View file

@ -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<usize> {
/// `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<usize> {
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<usi
return None;
}
let mut hit = None;
walk_rows(models, rect.origin.y, |row, y, h| {
// Walk from a scroll-shifted origin — the same offset paint
// applies via `translate` — then keep only hits whose row band
// actually falls inside the (unscrolled) card rect.
walk_rows(models, rect.origin.y - scroll, |row, y, h| {
if let Row::Model(idx) = row {
if point.y >= 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<usi
}
/// Paint the dropdown card + grouped rows. `selected` is the index
/// of the active model (gets a check mark). `rect` is the full
/// dropdown bounds as positioned by the caller.
/// of the active model (gets a check mark), `hover` the index of the
/// row under the cursor (gets a hover wash). `rect` is the painted
/// dropdown bounds (already capped at [`MODEL_PICKER_MAX_H`]);
/// `scroll` shifts the content up when the catalog overflows.
pub fn paint_model_picker(
cx: &mut PaintCx<'_>,
theme: &Theme,
rect: Rect,
models: &[ModelEntry],
selected: usize,
scroll: f32,
hover: Option<usize>,
) {
// 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<ModelEntry> = (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);
}
}

View file

@ -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<usize>,
/// 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<Rect> {
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<AIChatHit> {
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,
);
}
}

View file

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

View file

@ -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<bool>,
/// 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<Vec<RecentFilePayload>>,
}
@ -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<Locale> {
_ => 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]);
}
}

View file

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

View file

@ -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();
}

View file

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

View file

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

View file

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

View file

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