diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index 0004744a5a..0c90e68db1 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -34,8 +34,10 @@ use crate::ai::blocklist::inline_action::host_picker::HostPicker; use crate::ai::cloud_agent_settings::CloudAgentSettings; use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; use crate::ai::connected_self_hosted_workers::{ConnectedSelfHostedWorkersModel, WARP_WORKER_HOST}; -use crate::ai::execution_profiles::model_menu_items::available_model_menu_items; -use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel}; +use crate::ai::execution_profiles::model_menu_items::is_auto; +use crate::ai::harness_availability::{ + AuthSecretFetchState, HarnessAvailabilityModel, HarnessModelInfo, +}; use crate::ai::harness_display; use crate::ai::llms::LLMInfo; use crate::ai::local_harness_setup::{ @@ -70,6 +72,7 @@ pub const ORCHESTRATION_PICKER_RADIUS: f32 = 4.; pub const ORCHESTRATION_PICKER_MAX_WIDTH: f32 = 205.; const DEFAULT_MODEL_LABEL: &str = "Default model"; +const DEFAULT_EFFORT_LABEL: &str = "Default"; const ORCHESTRATION_SEGMENTED_CONTROL_PADDING: f32 = 4.; const ORCHESTRATION_SEGMENT_VERTICAL_PADDING: f32 = 4.; @@ -88,6 +91,7 @@ const AUTH_SECRET_CREATE_NEW_LABEL: &str = "New API key…"; pub trait OrchestrationControlAction: DropdownItemAction + Clone { fn execution_mode_toggled(is_remote: bool) -> Self; fn model_changed(model_id: String) -> Self; + fn effort_changed(model_id: String) -> Self; fn harness_changed(harness_type: String) -> Self; fn environment_changed(environment_id: String) -> Self; fn create_environment_requested() -> Self; @@ -351,6 +355,7 @@ impl OrchestrationEditState { #[derive(Clone)] pub struct OrchestrationPickerHandles { pub model_picker: Option>>, + pub effort_picker: Option>>, pub harness_picker: Option>>, pub environment_picker: Option>>, pub host_picker: Option>, @@ -367,6 +372,7 @@ impl Default for OrchestrationPickerHandles { fn default() -> Self { Self { model_picker: None, + effort_picker: None, harness_picker: None, environment_picker: None, host_picker: None, @@ -482,14 +488,169 @@ fn get_base_model_choices<'a>( .get_base_llm_choices_for_agent_mode(app) .filter(move |llm| is_local || llm_prefs.custom_llm_info_for_id(&llm.id).is_none()) } +fn oz_model_group_label(llm: &LLMInfo) -> String { + if is_auto(llm) { + "auto".to_string() + } else if llm.has_reasoning_level() { + llm.base_model_name().to_string() + } else { + llm.menu_display_name() + } +} + +fn oz_model_group_key(llm: &LLMInfo) -> String { + if is_auto(llm) { + "auto".to_string() + } else if llm.has_reasoning_level() { + format!("reasoning:{}", llm.base_model_name()) + } else { + format!("id:{}", llm.id) + } +} + +fn oz_effort_label(llm: &LLMInfo) -> String { + if is_auto(llm) && llm.display_name.starts_with("auto (") { + llm.display_name + .trim_start_matches("auto (") + .trim_end_matches(')') + .to_string() + } else { + llm.reasoning_level() + .unwrap_or_else(|| DEFAULT_EFFORT_LABEL.to_string()) + } +} + +fn selected_oz_model<'a>( + choices: &'a [&'a LLMInfo], + initial_model_id: &str, +) -> Option<&'a LLMInfo> { + choices + .iter() + .copied() + .find(|llm| llm.id.to_string() == initial_model_id) +} + +fn choose_oz_variant_for_group<'a>( + variants: &[&'a LLMInfo], + selected_effort: Option<&str>, +) -> &'a LLMInfo { + if let Some(effort) = selected_effort { + if let Some(choice) = variants + .iter() + .copied() + .find(|llm| oz_effort_label(llm).eq_ignore_ascii_case(effort)) + { + return choice; + } + } + variants[0] +} + +fn grouped_oz_models<'a>(choices: Vec<&'a LLMInfo>) -> Vec<(String, Vec<&'a LLMInfo>)> { + let mut groups: Vec<(String, String, Vec<&'a LLMInfo>)> = Vec::new(); + for llm in choices { + let key = oz_model_group_key(llm); + if let Some((_, _, variants)) = groups.iter_mut().find(|(existing, _, _)| *existing == key) + { + variants.push(llm); + } else { + groups.push((key, oz_model_group_label(llm), vec![llm])); + } + } + groups + .into_iter() + .map(|(_, label, variants)| (label, variants)) + .collect() +} + +fn strip_trailing_effort_label(display_name: &str, effort: &str) -> String { + let display = display_name.trim(); + let effort = effort.trim(); + if effort.is_empty() { + return display.to_string(); + } + let lower = display.to_lowercase(); + let effort_lower = effort.to_lowercase(); + for suffix in [ + format!(" ({effort_lower})"), + format!(" - {effort_lower}"), + format!(" – {effort_lower}"), + format!(" {effort_lower}"), + ] { + if lower.ends_with(&suffix) { + let keep_len = display.len().saturating_sub(suffix.len()); + return display[..keep_len].trim().to_string(); + } + } + display.to_string() +} + +fn harness_model_group_label(model: &HarnessModelInfo) -> String { + model + .reasoning_level + .as_deref() + .map(|effort| strip_trailing_effort_label(&model.display_name, effort)) + .filter(|label| !label.is_empty()) + .unwrap_or_else(|| model.display_name.clone()) +} + +fn harness_effort_label(model: &HarnessModelInfo) -> String { + model + .reasoning_level + .clone() + .unwrap_or_else(|| DEFAULT_EFFORT_LABEL.to_string()) +} + +fn selected_harness_model<'a>( + models: &'a [HarnessModelInfo], + initial_model_id: &str, +) -> Option<&'a HarnessModelInfo> { + models.iter().find(|model| model.id == initial_model_id) +} + +fn choose_harness_variant_for_group<'a>( + variants: &[&'a HarnessModelInfo], + selected_effort: Option<&str>, +) -> &'a HarnessModelInfo { + if let Some(effort) = selected_effort { + if let Some(choice) = variants + .iter() + .copied() + .find(|model| harness_effort_label(model).eq_ignore_ascii_case(effort)) + { + return choice; + } + } + variants[0] +} + +fn grouped_harness_models<'a>( + models: &'a [HarnessModelInfo], +) -> Vec<(String, Vec<&'a HarnessModelInfo>)> { + let mut groups: Vec<(String, Vec<&'a HarnessModelInfo>)> = Vec::new(); + for model in models { + let label = harness_model_group_label(model); + if let Some((_, variants)) = groups + .iter_mut() + .find(|(existing, _)| existing.eq_ignore_ascii_case(&label)) + { + variants.push(model); + } else { + groups.push((label, vec![model])); + } + } + groups +} + /// Populates the model picker based on the active harness. /// -/// - **Oz / empty**: shows the Warp LLM catalog (existing behavior). +/// - **Oz / empty**: shows unique Warp base models; effort variants are +/// moved into the effort picker. /// - **Local Codex**: shows only a "Default model" entry (no model delivery /// possible for local Codex children). /// - **Other non-Oz harnesses**: shows "Default model" at the top, followed -/// by the server-provided harness model catalog from -/// `HarnessAvailabilityModel::models_for()`. +/// by unique base models derived from the server-provided harness model +/// catalog from `HarnessAvailabilityModel::models_for()`. pub fn populate_model_picker_for_harness( dropdown: &ViewHandle>, initial_model_id: &str, @@ -504,8 +665,8 @@ pub fn populate_model_picker_for_harness match harness { Some(Harness::Oz) | None => { // Oz / unset: Warp LLM catalog. Custom models excluded for - // cloud runs (not supported by remote workers). - // Order: auto models first, then custom models, then other models. + // cloud runs (not supported by remote workers). Order: auto + // models first, then custom models, then other models. let llm_prefs = LLMPreferences::as_ref(ctx_dropdown); let (auto_models, rest): (Vec<_>, Vec<_>) = get_base_model_choices(llm_prefs, ctx_dropdown, is_local) @@ -518,23 +679,35 @@ pub fn populate_model_picker_for_harness .chain(custom_models) .chain(other_models) .collect(); - let selected_display_name = ordered_choices - .iter() - .find(|llm| llm.id.to_string() == initial_model_id) - .map(|llm| llm.menu_display_name()); - let items = available_model_menu_items( - ordered_choices, - move |llm| { - DropdownAction::select_action_and_close(A::model_changed( - llm.id.to_string(), - )) - }, - None, - None, - false, - false, - ctx_dropdown, - ); + let selected_effort = + selected_oz_model(&ordered_choices, &initial_model_id).map(oz_effort_label); + let groups = grouped_oz_models(ordered_choices); + let selected_display_name = groups.iter().find_map(|(label, variants)| { + variants + .iter() + .any(|llm| llm.id.to_string() == initial_model_id) + .then(|| label.clone()) + }); + let items = groups + .into_iter() + .map(|(label, variants)| { + let selected_id = + choose_oz_variant_for_group(&variants, selected_effort.as_deref()) + .id + .to_string(); + let icon = variants[0].provider.icon().unwrap_or(Icon::Oz); + MenuItem::Item( + MenuItemFields::new(label) + .with_icon(icon) + .with_on_select_action(DropdownAction::select_action_and_close( + A::model_changed(selected_id), + )) + .with_disabled( + variants.iter().all(|llm| llm.disable_reason.is_some()), + ), + ) + }) + .collect(); dropdown.set_rich_items(items, ctx_dropdown); if let Some(name) = &selected_display_name { dropdown.set_selected_by_name(name, ctx_dropdown); @@ -547,17 +720,21 @@ pub fn populate_model_picker_for_harness dropdown.set_selected_by_name(DEFAULT_MODEL_LABEL, ctx_dropdown); } Some(harness) => { - // Non-Oz harness: "Default model" at top, then server-provided - // harness models. + // Non-Oz harness: "Default model" at top, then unique base + // models derived from the server-provided harness models. let mut items: Vec> = vec![default_model_menu_item::()]; let availability = HarnessAvailabilityModel::as_ref(ctx_dropdown); if let Some(models) = availability.models_for(harness) { - for model in models { - let model_id = model.id.clone(); - let fields = MenuItemFields::new(&model.display_name) - .with_on_select_action(DropdownAction::select_action_and_close( - A::model_changed(model_id), - )); + let selected_effort = + selected_harness_model(models, &initial_model_id).map(harness_effort_label); + for (label, variants) in grouped_harness_models(models) { + let model_id = + choose_harness_variant_for_group(&variants, selected_effort.as_deref()) + .id + .clone(); + let fields = MenuItemFields::new(label).with_on_select_action( + DropdownAction::select_action_and_close(A::model_changed(model_id)), + ); items.push(MenuItem::Item(fields)); } } @@ -568,10 +745,14 @@ pub fn populate_model_picker_for_harness availability .models_for(harness) .and_then(|models| { - models - .iter() - .find(|m| m.id == initial_model_id) - .map(|m| m.display_name.clone()) + grouped_harness_models(models).into_iter().find_map( + |(label, variants)| { + variants + .iter() + .any(|m| m.id == initial_model_id) + .then_some(label) + }, + ) }) .or_else(|| Some(DEFAULT_MODEL_LABEL.to_string())) }; @@ -584,6 +765,90 @@ pub fn populate_model_picker_for_harness }); } +/// Populates the effort picker for the currently selected base model. +/// +/// The picker writes the original combined model ID back into `model_id`, so +/// persisted configs and run_agents requests remain backwards compatible. +pub fn populate_effort_picker_for_harness( + dropdown: &ViewHandle>, + initial_model_id: &str, + harness_type: &str, + is_local: bool, + ctx: &mut ViewContext, +) { + let initial_model_id = initial_model_id.to_string(); + let harness_type = harness_type.to_string(); + dropdown.update(ctx, |dropdown, ctx_dropdown| { + let harness = Harness::parse_orchestration_harness(&harness_type); + let mut items: Vec> = Vec::new(); + let mut selected_name = DEFAULT_EFFORT_LABEL.to_string(); + + match harness { + Some(Harness::Oz) | None => { + let llm_prefs = LLMPreferences::as_ref(ctx_dropdown); + let choices: Vec<_> = + get_base_model_choices(llm_prefs, ctx_dropdown, is_local).collect(); + if let Some(selected) = selected_oz_model(&choices, &initial_model_id) { + let selected_key = oz_model_group_key(selected); + selected_name = oz_effort_label(selected); + for llm in choices + .into_iter() + .filter(|llm| oz_model_group_key(llm) == selected_key) + { + let label = oz_effort_label(llm); + items.push(MenuItem::Item( + MenuItemFields::new(label) + .with_on_select_action(DropdownAction::select_action_and_close( + A::effort_changed(llm.id.to_string()), + )) + .with_disabled(llm.disable_reason.is_some()), + )); + } + } + } + Some(Harness::Codex) if is_local => {} + Some(harness) => { + if !initial_model_id.is_empty() { + let availability = HarnessAvailabilityModel::as_ref(ctx_dropdown); + if let Some(models) = availability.models_for(harness) { + if let Some(selected) = selected_harness_model(models, &initial_model_id) { + let selected_base = harness_model_group_label(selected); + selected_name = harness_effort_label(selected); + for model in models.iter().filter(|model| { + harness_model_group_label(model) + .eq_ignore_ascii_case(&selected_base) + }) { + let label = harness_effort_label(model); + items.push(MenuItem::Item( + MenuItemFields::new(label).with_on_select_action( + DropdownAction::select_action_and_close(A::effort_changed( + model.id.clone(), + )), + ), + )); + } + } + } + } + } + } + + if items.is_empty() { + items.push(MenuItem::Item( + MenuItemFields::new(DEFAULT_EFFORT_LABEL).with_disabled(true), + )); + } + let has_multiple_choices = items.iter().filter(|item| item.selectable()).count() > 1; + dropdown.set_rich_items(items, ctx_dropdown); + dropdown.set_selected_by_name(&selected_name, ctx_dropdown); + if has_multiple_choices { + dropdown.set_enabled(ctx_dropdown); + } else { + dropdown.set_disabled(ctx_dropdown); + } + }); +} + /// Creates a "Default model" menu item that emits an empty model_id. fn default_model_menu_item() -> MenuItem { MenuItem::Item( @@ -1424,6 +1689,15 @@ pub fn apply_harness_change( ctx, ); } + if let Some(handle) = &handles.effort_picker { + populate_effort_picker_for_harness( + handle, + &state.model_id, + &state.harness_type, + is_local, + ctx, + ); + } // Re-resolve auth selection from per-harness persisted state. // Honors an explicit `Inherit` choice for the new harness. @@ -1479,6 +1753,15 @@ pub fn apply_execution_mode_change( ctx, ); } + if let Some(handle) = &handles.effort_picker { + populate_effort_picker_for_harness( + handle, + &state.model_id, + &state.harness_type, + is_local, + ctx, + ); + } if let Some(handle) = &handles.host_picker { let initial_host = match &state.execution_mode { RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(), @@ -1522,6 +1805,15 @@ pub fn repopulate_all_pickers( ctx, ); } + if let Some(handle) = &handles.effort_picker { + populate_effort_picker_for_harness( + handle, + &state.model_id, + &state.harness_type, + is_local, + ctx, + ); + } // Drop any `Named(_)` selection whose secret no longer exists. if let Some(harness) = Harness::parse_orchestration_harness(&state.harness_type) { if harness != Harness::Oz { @@ -1576,22 +1868,19 @@ pub fn sync_picker_selections( let display_name = match harness { Some(Harness::Oz) | None => { let llm_prefs = LLMPreferences::as_ref(ctx_dropdown); - llm_prefs - .get_base_llm_choices_for_agent_mode(ctx_dropdown) - .find(|llm| llm.id.to_string() == target_model_id) - .map(|llm| llm.menu_display_name()) + let choices: Vec<_> = + get_base_model_choices(llm_prefs, ctx_dropdown, true).collect(); + selected_oz_model(&choices, &target_model_id).map(oz_model_group_label) } Some(harness) => { if target_model_id.is_empty() { Some(DEFAULT_MODEL_LABEL.to_string()) } else { let availability = HarnessAvailabilityModel::as_ref(ctx_dropdown); - availability.models_for(harness).and_then(|models| { - models - .iter() - .find(|m| m.id == target_model_id) - .map(|m| m.display_name.clone()) - }) + availability + .models_for(harness) + .and_then(|models| selected_harness_model(models, &target_model_id)) + .map(harness_model_group_label) } } }; @@ -1600,6 +1889,31 @@ pub fn sync_picker_selections( } }); } + if let Some(effort_picker) = handles.effort_picker.clone() { + let target_model_id = state.model_id.clone(); + let harness_type = state.harness_type.clone(); + effort_picker.update(ctx, |dropdown, ctx_dropdown| { + let harness = Harness::parse_orchestration_harness(&harness_type); + let display_name = match harness { + Some(Harness::Oz) | None => { + let llm_prefs = LLMPreferences::as_ref(ctx_dropdown); + let choices: Vec<_> = + get_base_model_choices(llm_prefs, ctx_dropdown, true).collect(); + selected_oz_model(&choices, &target_model_id).map(oz_effort_label) + } + Some(_) if target_model_id.is_empty() => Some(DEFAULT_EFFORT_LABEL.to_string()), + Some(harness) => { + let availability = HarnessAvailabilityModel::as_ref(ctx_dropdown); + availability + .models_for(harness) + .and_then(|models| selected_harness_model(models, &target_model_id)) + .map(harness_effort_label) + } + } + .unwrap_or_else(|| DEFAULT_EFFORT_LABEL.to_string()); + dropdown.set_selected_by_name(&display_name, ctx_dropdown); + }); + } if let Some(harness_picker) = handles.harness_picker.clone() { let harness_type = state.harness_type.clone(); let show_harness_picker = should_show_harness_picker(state); @@ -2000,6 +2314,14 @@ pub fn render_picker_row_with_layout( .as_ref() .map(|p| ChildView::new(p).finish()), ); + add( + &mut column, + "Effort", + handles + .effort_picker + .as_ref() + .map(|p| ChildView::new(p).finish()), + ); Container::new(column.finish()) .with_margin_top(12.) @@ -2049,6 +2371,14 @@ pub fn render_picker_row_with_layout( .as_ref() .map(|p| ChildView::new(p).finish()), ); + add_picker( + &mut row, + "Effort", + handles + .effort_picker + .as_ref() + .map(|p| ChildView::new(p).finish()), + ); if show_auth_picker { add_picker( &mut row, diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs b/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs index 96c47fb96e..12ea2bfbbb 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs @@ -2,9 +2,19 @@ use ai::agent::action::RunAgentsExecutionMode; use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationExecutionMode}; use super::{ - should_show_auth_secret_picker, should_show_harness_picker, AuthSecretSelection, + choose_harness_variant_for_group, grouped_harness_models, should_show_auth_secret_picker, + should_show_harness_picker, strip_trailing_effort_label, AuthSecretSelection, OrchestrationEditState, }; +use crate::ai::harness_availability::HarnessModelInfo; + +fn harness_model(id: &str, display_name: &str, reasoning_level: Option<&str>) -> HarnessModelInfo { + HarnessModelInfo { + id: id.to_string(), + display_name: display_name.to_string(), + reasoning_level: reasoning_level.map(str::to_string), + } +} fn remote_claude_state() -> OrchestrationEditState { OrchestrationEditState::from_run_agents_fields( @@ -18,6 +28,34 @@ fn remote_claude_state() -> OrchestrationEditState { ) } +#[test] +fn effort_suffix_is_stripped_from_harness_model_name() { + assert_eq!( + strip_trailing_effort_label("Claude Sonnet 4.5 (high)", "high"), + "Claude Sonnet 4.5" + ); + assert_eq!( + strip_trailing_effort_label("GPT 5.1 - Medium", "medium"), + "GPT 5.1" + ); +} + +#[test] +fn harness_models_group_by_base_name_and_preserve_effort() { + let models = vec![ + harness_model("sonnet-low", "Claude Sonnet 4.5 (low)", Some("low")), + harness_model("sonnet-high", "Claude Sonnet 4.5 (high)", Some("high")), + harness_model("opus-high", "Claude Opus 4.5 (high)", Some("high")), + ]; + let groups = grouped_harness_models(&models); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].0, "Claude Sonnet 4.5"); + assert_eq!(groups[0].1.len(), 2); + + let selected = choose_harness_variant_for_group(&groups[0].1, Some("high")); + assert_eq!(selected.id, "sonnet-high"); +} + fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig { OrchestrationConfig { model_id: model_id.to_string(), diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 48e51fa78c..594509be8a 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -155,6 +155,9 @@ impl OrchestrationControlAction for RunAgentsCardViewAction { fn model_changed(model_id: String) -> Self { Self::ModelChanged { model_id } } + fn effort_changed(model_id: String) -> Self { + Self::EffortChanged { model_id } + } fn harness_changed(harness_type: String) -> Self { Self::HarnessChanged { harness_type } } @@ -192,6 +195,9 @@ pub enum RunAgentsCardViewAction { ModelChanged { model_id: String, }, + EffortChanged { + model_id: String, + }, HarnessChanged { harness_type: String, }, @@ -411,6 +417,16 @@ impl RunAgentsCardView { ctx, ); } + if let Some(handle) = &me.handles.pickers.effort_picker { + let is_local = !me.state.orch.execution_mode.is_remote(); + oc::populate_effort_picker_for_harness( + handle, + &me.state.orch.model_id, + &me.state.orch.harness_type, + is_local, + ctx, + ); + } } }); @@ -746,6 +762,25 @@ impl RunAgentsCardView { Self::subscribe_filterable_picker_close(&handle, ctx); self.handles.pickers.model_picker = Some(handle); } + if self.handles.pickers.effort_picker.is_none() { + let initial_model_id = if state.orch.model_id.trim().is_empty() { + initial_model_id_default.clone() + } else { + state.orch.model_id.clone() + }; + let is_local = !state.orch.execution_mode.is_remote(); + let handle = oc::new_standard_picker_dropdown(&colors, ctx); + Self::set_upward_menu_position(&handle, ctx); + oc::populate_effort_picker_for_harness( + &handle, + &initial_model_id, + &state.orch.harness_type, + is_local, + ctx, + ); + Self::subscribe_picker_close(&handle, ctx); + self.handles.pickers.effort_picker = Some(handle); + } if self.handles.pickers.harness_picker.is_none() { let handle = oc::new_standard_picker_dropdown(&colors, ctx); @@ -1077,6 +1112,20 @@ impl TypedActionView for RunAgentsCardView { ctx.notify(); } RunAgentsCardViewAction::ModelChanged { model_id } => { + self.state.orch.model_id = model_id.clone(); + if let Some(handle) = &self.handles.pickers.effort_picker { + oc::populate_effort_picker_for_harness( + handle, + &self.state.orch.model_id, + &self.state.orch.harness_type, + !self.state.orch.execution_mode.is_remote(), + ctx, + ); + } + self.refresh_accept_button_state(ctx); + ctx.notify(); + } + RunAgentsCardViewAction::EffortChanged { model_id } => { self.state.orch.model_id = model_id.clone(); self.refresh_accept_button_state(ctx); ctx.notify(); diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index d5f39cdce8..67105eb663 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -122,6 +122,9 @@ pub enum OrchestrationConfigBlockAction { ModelChanged { model_id: String, }, + EffortChanged { + model_id: String, + }, HarnessChanged { harness_type: String, }, @@ -146,6 +149,9 @@ impl OrchestrationControlAction for OrchestrationConfigBlockAction { fn model_changed(model_id: String) -> Self { Self::ModelChanged { model_id } } + fn effort_changed(model_id: String) -> Self { + Self::EffortChanged { model_id } + } fn harness_changed(harness_type: String) -> Self { Self::HarnessChanged { harness_type } } @@ -257,6 +263,16 @@ impl OrchestrationConfigBlockView { ctx, ); } + if let Some(handle) = &me.pickers.effort_picker { + let is_local = !me.edit_state.execution_mode.is_remote(); + oc::populate_effort_picker_for_harness( + handle, + &me.edit_state.model_id, + &me.edit_state.harness_type, + is_local, + ctx, + ); + } } }); @@ -453,6 +469,16 @@ impl OrchestrationConfigBlockView { ); self.pickers.model_picker = Some(model_handle); + let effort_handle = oc::new_standard_picker_dropdown(&colors, ctx); + effort_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); + oc::populate_effort_picker_for_harness( + &effort_handle, + &display_model_id, + &self.edit_state.harness_type, + is_local, + ctx, + ); + self.pickers.effort_picker = Some(effort_handle); let harness_handle = oc::new_standard_picker_dropdown(&colors, ctx); harness_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); oc::populate_harness_picker( @@ -826,6 +852,20 @@ impl TypedActionView for OrchestrationConfigBlockView { ctx.notify(); } OrchestrationConfigBlockAction::ModelChanged { model_id } => { + self.edit_state.model_id = model_id.clone(); + if let Some(handle) = &self.pickers.effort_picker { + oc::populate_effort_picker_for_harness( + handle, + &self.edit_state.model_id, + &self.edit_state.harness_type, + !self.edit_state.execution_mode.is_remote(), + ctx, + ); + } + self.apply_field_change(ctx); + ctx.notify(); + } + OrchestrationConfigBlockAction::EffortChanged { model_id } => { self.edit_state.model_id = model_id.clone(); self.apply_field_change(ctx); ctx.notify(); diff --git a/app/src/ai/onboarding.rs b/app/src/ai/onboarding.rs index f43abc0f51..63d0572594 100644 --- a/app/src/ai/onboarding.rs +++ b/app/src/ai/onboarding.rs @@ -6,15 +6,42 @@ use onboarding::OnboardingAuthState; use warp_core::ui::icons::Icon; use warpui::{AppContext, SingletonEntity}; +use super::execution_profiles::model_menu_items::is_auto; use super::llms::{DisableReason, LLMInfo, LLMPreferences}; use crate::auth::AuthStateProvider; use crate::workspaces::user_workspaces::UserWorkspaces; +const DEFAULT_EFFORT_LABEL: &str = "Default"; + +fn onboarding_base_title(llm: &LLMInfo) -> String { + if is_auto(llm) { + "auto".to_string() + } else if llm.has_reasoning_level() { + llm.base_model_name().to_string() + } else { + llm.menu_display_name() + } +} + +fn onboarding_effort_title(llm: &LLMInfo) -> String { + if is_auto(llm) && llm.display_name.starts_with("auto (") { + llm.display_name + .trim_start_matches("auto (") + .trim_end_matches(')') + .to_string() + } else { + llm.reasoning_level() + .unwrap_or_else(|| DEFAULT_EFFORT_LABEL.to_string()) + } +} + impl From<&LLMInfo> for OnboardingModelInfo { fn from(llm: &LLMInfo) -> Self { Self { id: llm.id.clone(), title: llm.display_name.clone(), + base_title: onboarding_base_title(llm), + effort_title: onboarding_effort_title(llm), icon: llm.provider.icon().unwrap_or(Icon::Oz), requires_upgrade: matches!(llm.disable_reason, Some(DisableReason::RequiresUpgrade)), is_default: false, diff --git a/crates/onboarding/src/bin/main.rs b/crates/onboarding/src/bin/main.rs index 0f8521c517..3e384081b1 100644 --- a/crates/onboarding/src/bin/main.rs +++ b/crates/onboarding/src/bin/main.rs @@ -89,6 +89,8 @@ impl OnboardingMainView { OnboardingModelInfo { id: LLMId::from("auto"), title: "Auto".to_string(), + base_title: "Auto".to_string(), + effort_title: "Default".to_string(), icon: Icon::Oz, requires_upgrade: false, is_default: true, @@ -96,6 +98,8 @@ impl OnboardingMainView { OnboardingModelInfo { id: LLMId::from("claude-sonnet"), title: "Claude Sonnet".to_string(), + base_title: "Claude Sonnet".to_string(), + effort_title: "Default".to_string(), icon: Icon::ClaudeLogo, requires_upgrade: false, is_default: false, @@ -103,6 +107,8 @@ impl OnboardingMainView { OnboardingModelInfo { id: LLMId::from("gpt-4o"), title: "GPT-4o".to_string(), + base_title: "GPT-4o".to_string(), + effort_title: "Default".to_string(), icon: Icon::OpenAILogo, requires_upgrade: true, is_default: false, diff --git a/crates/onboarding/src/slides/agent_slide.rs b/crates/onboarding/src/slides/agent_slide.rs index 28a15ed929..9cc979c835 100644 --- a/crates/onboarding/src/slides/agent_slide.rs +++ b/crates/onboarding/src/slides/agent_slide.rs @@ -70,7 +70,12 @@ impl button::Theme for UpgradeButtonTheme { #[derive(Clone, Debug)] pub struct OnboardingModelInfo { pub id: LLMId, + /// Full display title for the concrete model variant. pub title: String, + /// UI-only base model title used to collapse effort/reasoning variants. + pub base_title: String, + /// UI-only effort/reasoning label for this concrete model variant. + pub effort_title: String, pub icon: Icon, pub requires_upgrade: bool, pub is_default: bool, @@ -126,8 +131,12 @@ impl AgentDevelopmentSettings { pub enum AgentSlideAction { /// Select model by its ID. When the picker is expanded this also collapses it. SelectModel(LLMId), - /// Toggle the expanded state of the collapsed picker chip. + /// Select the best concrete model variant for a base model group. + SelectModelGroup(String), + /// Toggle the expanded state of the collapsed model picker chip. ToggleModelListExpanded, + /// Toggle the expanded state of the collapsed effort picker chip. + ToggleEffortListExpanded, /// Update the keyboard/hover highlight cursor to the given model id. /// Dispatched from hover handlers on enabled rows. HighlightModel(LLMId), @@ -155,6 +164,9 @@ pub struct AgentSlide { /// Mouse state handle for the collapsed model-picker chip (closed-state click target). chip_mouse_state: MouseStateHandle, + /// Mouse state handle for the collapsed effort-picker chip (closed-state click target). + effort_chip_mouse_state: MouseStateHandle, + effort_mouse_states: Vec, autonomy_full_mouse_state: MouseStateHandle, autonomy_partial_mouse_state: MouseStateHandle, @@ -167,7 +179,9 @@ pub struct AgentSlide { upgrade_button: button::Button, scroll_state: ClippedScrollStateHandle, dropdown_scroll_state: ClippedScrollStateHandle, + effort_dropdown_scroll_state: ClippedScrollStateHandle, is_model_list_expanded: bool, + is_effort_list_expanded: bool, highlighted_model_id: Option, show_auth_prompt_bar: bool, copy_url_mouse_state: MouseStateHandle, @@ -195,6 +209,83 @@ fn sorted_models(models: &[OnboardingModelInfo]) -> Vec { free.into_iter().chain(premium).collect() } +#[derive(Clone)] +struct OnboardingModelGroup { + title: String, + icon: Icon, + requires_upgrade: bool, + is_default: bool, + variants: Vec, +} + +fn selected_model<'a>( + models: &'a [OnboardingModelInfo], + settings: &AgentDevelopmentSettings, +) -> Option<&'a OnboardingModelInfo> { + models + .iter() + .find(|m| m.id == settings.selected_model_id) + .or_else(|| models.first()) +} + +fn sorted_model_groups(models: &[OnboardingModelInfo]) -> Vec { + let mut groups: Vec = Vec::new(); + for model in sorted_models(models) { + if let Some(group) = groups + .iter_mut() + .find(|group| group.title.eq_ignore_ascii_case(&model.base_title)) + { + group.requires_upgrade &= model.requires_upgrade; + group.is_default |= model.is_default; + group.variants.push(model); + } else { + groups.push(OnboardingModelGroup { + title: model.base_title.clone(), + icon: model.icon, + requires_upgrade: model.requires_upgrade, + is_default: model.is_default, + variants: vec![model], + }); + } + } + groups +} + +fn variants_for_selected_group( + models: &[OnboardingModelInfo], + settings: &AgentDevelopmentSettings, +) -> Vec { + let Some(selected) = selected_model(models, settings) else { + return Vec::new(); + }; + sorted_models(models) + .into_iter() + .filter(|model| model.base_title.eq_ignore_ascii_case(&selected.base_title)) + .collect() +} + +fn choose_variant_for_group( + variants: &[OnboardingModelInfo], + selected_effort: Option<&str>, +) -> Option { + selected_effort + .and_then(|effort| { + variants + .iter() + .find(|model| { + !model.requires_upgrade && model.effort_title.eq_ignore_ascii_case(effort) + }) + .map(|model| model.id.clone()) + }) + .or_else(|| { + variants + .iter() + .find(|model| !model.requires_upgrade) + .map(|model| model.id.clone()) + }) + .or_else(|| variants.first().map(|model| model.id.clone())) +} + impl AgentSlide { pub(crate) fn new( onboarding_state: warpui_core::ModelHandle, @@ -204,6 +295,9 @@ impl AgentSlide { let model_mouse_states = (0..model_count) .map(|_| MouseStateHandle::default()) .collect(); + let effort_mouse_states = (0..model_count) + .map(|_| MouseStateHandle::default()) + .collect(); let initial_auth_state = onboarding_state.as_ref(ctx).auth_state(); @@ -221,6 +315,7 @@ impl AgentSlide { } let model_count = state.models().len(); me.ensure_mouse_states_for_models(model_count, ctx); + me.ensure_mouse_states_for_efforts(model_count, ctx); } OnboardingStateEvent::AuthStateChanged => { let new_state = model.as_ref(ctx).auth_state(); @@ -253,6 +348,8 @@ impl AgentSlide { onboarding_state, model_mouse_states, chip_mouse_state: MouseStateHandle::default(), + effort_chip_mouse_state: MouseStateHandle::default(), + effort_mouse_states, autonomy_full_mouse_state: MouseStateHandle::default(), autonomy_partial_mouse_state: MouseStateHandle::default(), autonomy_none_mouse_state: MouseStateHandle::default(), @@ -262,7 +359,9 @@ impl AgentSlide { upgrade_button: button::Button::default(), scroll_state: ClippedScrollStateHandle::new(), dropdown_scroll_state: ClippedScrollStateHandle::new(), + effort_dropdown_scroll_state: ClippedScrollStateHandle::new(), is_model_list_expanded: false, + is_effort_list_expanded: false, highlighted_model_id: None, show_auth_prompt_bar: false, copy_url_mouse_state: MouseStateHandle::default(), @@ -283,6 +382,30 @@ impl AgentSlide { ctx.notify(); } + fn set_effort_list_expanded(&mut self, expanded: bool, ctx: &mut ViewContext) { + if self.is_effort_list_expanded == expanded { + return; + } + self.is_effort_list_expanded = expanded; + if expanded { + self.is_model_list_expanded = false; + } + ctx.notify(); + } + + fn ensure_mouse_states_for_efforts( + &mut self, + effort_count: usize, + ctx: &mut ViewContext, + ) { + if self.effort_mouse_states.len() < effort_count { + self.effort_mouse_states.extend( + (self.effort_mouse_states.len()..effort_count).map(|_| MouseStateHandle::default()), + ); + } + ctx.notify(); + } + fn agent_settings<'a>(&self, app: &'a AppContext) -> &'a AgentDevelopmentSettings { self.onboarding_state.as_ref(app).agent_settings() } @@ -424,35 +547,8 @@ impl AgentSlide { settings: &AgentDevelopmentSettings, app: &AppContext, ) -> Box { - let header = self.render_section_header("Default model", appearance); - - let expanded = self.is_model_list_expanded; - let chip = self.render_collapsed_model_chip(appearance, settings, app, expanded); - - // Wrap the chip in a `Stack` so the floating dropdown overlay can live - // inline here (as a child of the left column) and inherit the chip's - // full column width via `ParentOffsetBounds::ParentBySize`. When the - // picker is collapsed the Stack has just the chip as its single child - // and lays out exactly like a bare chip would. - let mut chip_stack = Stack::new().with_child(chip); - if expanded { - let positioning = OffsetPositioning::from_axes( - PositioningAxis::relative_to_parent( - ParentOffsetBounds::ParentBySize, - OffsetType::Pixel(0.), - AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left), - ), - PositioningAxis::relative_to_parent( - ParentOffsetBounds::Unbounded, - OffsetType::Pixel(4.), - AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top), - ), - ); - chip_stack.add_positioned_overlay_child( - self.render_model_list_overlay(appearance, app), - positioning, - ); - } + let model_picker = self.render_model_picker_section(appearance, settings, app); + let effort_picker = self.render_effort_picker_section(appearance, settings, app); let has_disabled = self .onboarding_state @@ -463,10 +559,18 @@ impl AgentSlide { let mut col = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child(header) .with_child( - Container::new(chip_stack.finish()) - .with_margin_top(12.) + Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Start) + .with_child(ConstrainedBox::new(model_picker).with_width(260.).finish()) + .with_child( + Container::new( + ConstrainedBox::new(effort_picker).with_width(180.).finish(), + ) + .with_margin_left(12.) + .finish(), + ) .finish(), ); @@ -481,6 +585,83 @@ impl AgentSlide { col.finish() } + fn render_model_picker_section( + &self, + appearance: &Appearance, + settings: &AgentDevelopmentSettings, + app: &AppContext, + ) -> Box { + let header = self.render_section_header("Default model", appearance); + let chip = self.render_collapsed_model_chip( + appearance, + settings, + app, + self.is_model_list_expanded, + ); + let stack = self.render_picker_stack( + chip, + self.is_model_list_expanded, + self.render_model_list_overlay(appearance, app), + ); + + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child(header) + .with_child(Container::new(stack).with_margin_top(12.).finish()) + .finish() + } + + fn render_effort_picker_section( + &self, + appearance: &Appearance, + settings: &AgentDevelopmentSettings, + app: &AppContext, + ) -> Box { + let header = self.render_section_header("Effort level", appearance); + let chip = self.render_collapsed_effort_chip( + appearance, + settings, + app, + self.is_effort_list_expanded, + ); + let stack = self.render_picker_stack( + chip, + self.is_effort_list_expanded, + self.render_effort_list_overlay(appearance, app), + ); + + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child(header) + .with_child(Container::new(stack).with_margin_top(12.).finish()) + .finish() + } + + fn render_picker_stack( + &self, + chip: Box, + expanded: bool, + overlay: Box, + ) -> Box { + let mut stack = Stack::new().with_child(chip); + if expanded { + let positioning = OffsetPositioning::from_axes( + PositioningAxis::relative_to_parent( + ParentOffsetBounds::ParentBySize, + OffsetType::Pixel(0.), + AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left), + ), + PositioningAxis::relative_to_parent( + ParentOffsetBounds::Unbounded, + OffsetType::Pixel(4.), + AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top), + ), + ); + stack.add_positioned_overlay_child(overlay, positioning); + } + stack.finish() + } + /// Renders the single-row collapsed picker button: provider icon, selected title, /// and trailing chevron. `expanded` controls whether the chip draws its blue /// "focused" border (when the full-list view is active) or the neutral border. @@ -499,13 +680,10 @@ impl AgentSlide { let ui_font_family = appearance.ui_font_family(); let models = self.onboarding_state.as_ref(app).models(); - let selected = models - .iter() - .find(|m| m.id == settings.selected_model_id) - .or_else(|| models.first()); + let selected = selected_model(models, settings); let (title_text, icon) = match selected { - Some(model) => (model.title.clone(), Some(model.icon)), + Some(model) => (model.base_title.clone(), Some(model.icon)), None => ("".to_string(), None), }; let is_disabled = settings.disable_oz; @@ -590,10 +768,96 @@ impl AgentSlide { } } - /// Renders the vertical list of model rows shown inside the floating dropdown - /// overlay. Each row: provider icon + title on the left, pill on the right - /// (Premium for paywalled rows). Disabled rows are rendered dimmed and are - /// not clickable or hover-selectable. + fn render_collapsed_effort_chip( + &self, + appearance: &Appearance, + settings: &AgentDevelopmentSettings, + app: &AppContext, + expanded: bool, + ) -> Box { + const CHIP_HEIGHT: f32 = 48.; + const CHIP_RADIUS: f32 = 8.; + + let theme = appearance.theme(); + let background_for_text = theme.background().into_solid(); + let ui_font_family = appearance.ui_font_family(); + + let models = self.onboarding_state.as_ref(app).models(); + let selected = selected_model(models, settings); + let title_text = selected + .map(|model| model.effort_title.clone()) + .unwrap_or_else(|| "Default".to_string()); + let has_multiple_efforts = variants_for_selected_group(models, settings) + .iter() + .filter(|model| !model.requires_upgrade) + .count() + > 1; + let is_disabled = settings.disable_oz || !has_multiple_efforts; + + let title_color: ColorU = if is_disabled { + internal_colors::text_disabled(theme, background_for_text) + } else { + internal_colors::text_main(theme, background_for_text) + }; + + let border_color = if expanded && !is_disabled { + theme.accent() + } else { + Fill::Solid(internal_colors::neutral_4(theme)) + }; + + let mouse_state = self.effort_chip_mouse_state.clone(); + + let hoverable = Hoverable::new(mouse_state, move |_| { + let title_el = Text::new(title_text.clone(), ui_font_family, 14.0) + .with_color(title_color) + .with_style(Properties { + weight: Weight::Normal, + ..Default::default() + }) + .with_line_height_ratio(1.0) + .finish(); + + let chevron = ConstrainedBox::new(Box::new(WarpUiIcon::new( + "bundled/svg/chevron-down.svg", + title_color, + ))) + .with_width(14.) + .with_height(14.) + .finish(); + + let row = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(title_el) + .with_child(chevron) + .finish(); + + ConstrainedBox::new( + Container::new(row) + .with_horizontal_padding(16.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(CHIP_RADIUS))) + .with_border(Border::all(1.).with_border_fill(border_color)) + .finish(), + ) + .with_min_height(CHIP_HEIGHT) + .finish() + }); + + if is_disabled { + hoverable.finish() + } else { + hoverable + .with_cursor(Cursor::PointingHand) + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action(AgentSlideAction::ToggleEffortListExpanded); + }) + .finish() + } + } + + /// Renders the vertical list of grouped base-model rows shown inside the floating dropdown. fn render_model_list_rows( &self, appearance: &Appearance, @@ -605,25 +869,36 @@ impl AgentSlide { let state = self.onboarding_state.as_ref(app); let highlighted_id = self.highlighted_model_id.clone(); let selected_id = state.agent_settings().selected_model_id.clone(); - let models = sorted_models(state.models()); + let selected_base = selected_model(state.models(), state.agent_settings()) + .map(|model| model.base_title.clone()); + let groups = sorted_model_groups(state.models()); let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - for (index, model) in models.iter().enumerate() { + for (index, group) in groups.iter().enumerate() { let mouse_state = self .model_mouse_states .get(index) .cloned() .unwrap_or_default(); - let is_highlighted = highlighted_id.as_ref() == Some(&model.id) - || (highlighted_id.is_none() && model.id == selected_id); - let row = - self.render_model_row(appearance, model, is_highlighted, mouse_state, ROW_HEIGHT); - // Wrap each row in `SavePosition` so the scrollable can scroll - // the keyboard-highlighted row into view (see - // `advance_highlighted_model`). Mirrors the pattern in - // `VerticalTabsPanelState::scroll_to_tab`. + let highlighted_base = highlighted_id.as_ref().and_then(|id| { + group + .variants + .iter() + .find(|model| &model.id == id) + .map(|model| model.base_title.clone()) + }); + let is_highlighted = highlighted_base.as_ref() == Some(&group.title) + || (highlighted_id.is_none() && selected_base.as_ref() == Some(&group.title)); + let row = self.render_model_group_row( + appearance, + group, + is_highlighted, + mouse_state, + ROW_HEIGHT, + selected_id.clone(), + ); let row = SavePosition::new(row, &model_row_position_id(index)).finish(); let margin_top = if index == 0 { 0. } else { ROW_GAP }; @@ -632,22 +907,74 @@ impl AgentSlide { col.finish() } + fn render_effort_list_rows( + &self, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + const ROW_HEIGHT: f32 = 48.; + const ROW_GAP: f32 = 2.; + + let state = self.onboarding_state.as_ref(app); + let selected_id = state.agent_settings().selected_model_id.clone(); + let variants = variants_for_selected_group(state.models(), state.agent_settings()); + let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + + for (index, model) in variants.iter().enumerate() { + let mouse_state = self + .effort_mouse_states + .get(index) + .cloned() + .unwrap_or_default(); + let is_highlighted = model.id == selected_id; + let row = + self.render_effort_row(appearance, model, is_highlighted, mouse_state, ROW_HEIGHT); + let margin_top = if index == 0 { 0. } else { ROW_GAP }; + col = col.with_child(Container::new(row).with_margin_top(margin_top).finish()); + } + col.finish() + } + fn render_model_list_overlay( &self, appearance: &Appearance, app: &AppContext, + ) -> Box { + self.render_picker_overlay( + self.render_model_list_rows(appearance, app), + self.dropdown_scroll_state.clone(), + AgentSlideAction::ToggleModelListExpanded, + appearance, + ) + } + + fn render_effort_list_overlay( + &self, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + self.render_picker_overlay( + self.render_effort_list_rows(appearance, app), + self.effort_dropdown_scroll_state.clone(), + AgentSlideAction::ToggleEffortListExpanded, + appearance, + ) + } + + fn render_picker_overlay( + &self, + list: Box, + scroll_state: ClippedScrollStateHandle, + dismiss_action: AgentSlideAction, + appearance: &Appearance, ) -> Box { const OVERLAY_RADIUS: f32 = 8.; const OVERLAY_PADDING: f32 = 4.; const OVERLAY_MAX_HEIGHT: f32 = 400.; let theme = appearance.theme(); - let list = self.render_model_list_rows(appearance, app); - - // Wrap the list in a vertical `ClippedScrollable` so rows scroll when - // they exceed `OVERLAY_MAX_HEIGHT`. let scrollable = ClippedScrollable::vertical( - self.dropdown_scroll_state.clone(), + scroll_state, list, ScrollbarWidth::Auto, theme.disabled_text_color(theme.surface_1()).into(), @@ -671,42 +998,66 @@ impl AgentSlide { Dismiss::new(card) .prevent_interaction_with_other_elements() - .on_dismiss(|ctx, _| { - ctx.dispatch_typed_action(AgentSlideAction::ToggleModelListExpanded); + .on_dismiss(move |ctx, _| { + ctx.dispatch_typed_action(dismiss_action.clone()); + }) + .finish() + } + + fn render_pill(&self, label: &'static str, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + let background_for_text = theme.background().into_solid(); + let badge = Text::new(label.to_string(), appearance.ui_font_family(), 11.0) + .with_color(internal_colors::text_sub(theme, background_for_text)) + .with_style(Properties { + weight: Weight::Normal, + ..Default::default() }) + .with_line_height_ratio(1.0) + .finish(); + Container::new(badge) + .with_padding_left(8.) + .with_padding_right(8.) + .with_padding_top(4.) + .with_padding_bottom(4.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .with_background(Fill::Solid(internal_colors::neutral_3(theme))) .finish() } - fn render_model_row( + fn render_model_group_row( &self, appearance: &Appearance, - model: &OnboardingModelInfo, + group: &OnboardingModelGroup, is_highlighted: bool, mouse_state: MouseStateHandle, height: f32, + selected_id: LLMId, ) -> Box { const ROW_RADIUS: f32 = 6.; let theme = appearance.theme(); let background_for_text = theme.background().into_solid(); let ui_font_family = appearance.ui_font_family(); - - let is_disabled = model.requires_upgrade; - + let is_disabled = group.requires_upgrade; let title_color: ColorU = if is_disabled { internal_colors::text_disabled(theme, background_for_text) } else { internal_colors::text_main(theme, background_for_text) }; - let row_id = model.id.clone(); - let title = model.title.clone(); - let icon = model.icon; - let requires_upgrade = model.requires_upgrade; - let is_default = model.is_default; + let title = group.title.clone(); + let icon = group.icon; + let requires_upgrade = group.requires_upgrade; + let is_default = group.is_default; + let group_variants = group.variants.clone(); + let click_group_title = title.clone(); + let hover_id = choose_variant_for_group(&group_variants, None); + let row_title = title; + let row_variants = group_variants.clone(); let hoverable_body = Hoverable::new(mouse_state, move |_| { - let title_el = Text::new(title.clone(), ui_font_family, 14.0) + let title_el = Text::new(row_title.clone(), ui_font_family, 14.0) .with_color(title_color) .with_style(Properties { weight: Weight::Normal, @@ -725,34 +1076,13 @@ impl AgentSlide { .with_child(icon_el) .with_child(Container::new(title_el).with_margin_left(8.).finish()) .finish(); - - // Trailing pills: "Recommended" on the server-designated default - // model, "Premium" on paywalled rows. In practice a single row is - // at most one of these, but both can be shown side-by-side if the - // default is also premium for any reason. - let make_pill = |label: &'static str| -> Box { - let badge = Text::new(label.to_string(), ui_font_family, 11.0) - .with_color(internal_colors::text_sub(theme, background_for_text)) - .with_style(Properties { - weight: Weight::Normal, - ..Default::default() - }) - .with_line_height_ratio(1.0) - .finish(); - Container::new(badge) - .with_padding_left(8.) - .with_padding_right(8.) - .with_padding_top(4.) - .with_padding_bottom(4.) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) - .with_background(Fill::Solid(internal_colors::neutral_3(theme))) - .finish() - }; - - let trailing: Box = if is_default { - make_pill("Recommended") - } else if requires_upgrade { - make_pill("Premium") + let is_selected = row_variants.iter().any(|model| model.id == selected_id); + let trailing: Box = if requires_upgrade { + self.render_pill("Premium", appearance) + } else if is_default { + self.render_pill("Recommended", appearance) + } else if is_selected { + self.render_pill("Selected", appearance) } else { Empty::new().finish() }; @@ -784,20 +1114,93 @@ impl AgentSlide { }); if is_disabled { - // Disabled rows: no click, no hover-updates-highlight, muted. hoverable_body.finish() } else { - let click_id = row_id.clone(); - let hover_id = row_id; hoverable_body .with_cursor(Cursor::PointingHand) .on_hover(move |is_hovered, ctx, _, _| { - if is_hovered { + if let (true, Some(hover_id)) = (is_hovered, hover_id.as_ref()) { ctx.dispatch_typed_action(AgentSlideAction::HighlightModel( hover_id.clone(), )); } }) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(AgentSlideAction::SelectModelGroup( + click_group_title.clone(), + )); + }) + .finish() + } + } + + fn render_effort_row( + &self, + appearance: &Appearance, + model: &OnboardingModelInfo, + is_highlighted: bool, + mouse_state: MouseStateHandle, + height: f32, + ) -> Box { + const ROW_RADIUS: f32 = 6.; + + let theme = appearance.theme(); + let background_for_text = theme.background().into_solid(); + let ui_font_family = appearance.ui_font_family(); + let is_disabled = model.requires_upgrade; + let title_color: ColorU = if is_disabled { + internal_colors::text_disabled(theme, background_for_text) + } else { + internal_colors::text_main(theme, background_for_text) + }; + let click_id = model.id.clone(); + let title = model.effort_title.clone(); + let requires_upgrade = model.requires_upgrade; + + let hoverable_body = Hoverable::new(mouse_state, move |_| { + let title_el = Text::new(title.clone(), ui_font_family, 14.0) + .with_color(title_color) + .with_style(Properties { + weight: Weight::Normal, + ..Default::default() + }) + .with_line_height_ratio(1.0) + .finish(); + let trailing: Box = if requires_upgrade { + self.render_pill("Premium", appearance) + } else if is_highlighted { + self.render_pill("Selected", appearance) + } else { + Empty::new().finish() + }; + let row = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(title_el) + .with_child(trailing) + .finish(); + let background = if is_highlighted && !is_disabled { + Some(Fill::Solid(internal_colors::neutral_2(theme))) + } else { + None + }; + let mut container = Container::new(row) + .with_horizontal_padding(12.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(ROW_RADIUS))); + if let Some(bg) = background { + container = container.with_background(bg); + } + ConstrainedBox::new(container.finish()) + .with_min_height(height) + .finish() + }); + + if is_disabled { + hoverable_body.finish() + } else { + hoverable_body + .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(AgentSlideAction::SelectModel(click_id.clone())); }) @@ -1374,19 +1777,40 @@ impl AgentSlide { ctx.notify(); } + fn select_model_group(&mut self, base_title: &str, ctx: &mut ViewContext) { + let state = self.onboarding_state.as_ref(ctx); + let selected_effort = selected_model(state.models(), state.agent_settings()) + .map(|model| model.effort_title.clone()); + let groups = sorted_model_groups(state.models()); + let Some(group) = groups + .iter() + .find(|group| group.title.eq_ignore_ascii_case(base_title)) + else { + return; + }; + let Some(model_id) = choose_variant_for_group(&group.variants, selected_effort.as_deref()) + else { + return; + }; + self.select_model(model_id, ctx); + } + fn set_model_list_expanded(&mut self, expanded: bool, ctx: &mut ViewContext) { if self.is_model_list_expanded == expanded { return; } self.is_model_list_expanded = expanded; if expanded { + self.is_effort_list_expanded = false; // Seed the highlight from the current selection so keyboard nav // starts on the selected row. let state = self.onboarding_state.as_ref(ctx); let selected_id = state.agent_settings().selected_model_id.clone(); - if let Some(index) = sorted_models(state.models()) + let selected_base = selected_model(state.models(), state.agent_settings()) + .map(|model| model.base_title.clone()); + if let Some(index) = sorted_model_groups(state.models()) .iter() - .position(|m| m.id == selected_id) + .position(|group| selected_base.as_ref() == Some(&group.title)) { self.dropdown_scroll_state.scroll_to_position(ScrollTarget { position_id: model_row_position_id(index), @@ -1485,6 +1909,9 @@ impl OnboardingSlide for AgentSlide { self.advance_highlighted_model(/* forward */ false, ctx); return; } + if self.is_effort_list_expanded { + return; + } if self.workspace_enforces_autonomy(ctx) { return; } @@ -1508,6 +1935,9 @@ impl OnboardingSlide for AgentSlide { self.advance_highlighted_model(/* forward */ true, ctx); return; } + if self.is_effort_list_expanded { + return; + } if self.workspace_enforces_autonomy(ctx) { return; } @@ -1542,6 +1972,10 @@ impl OnboardingSlide for AgentSlide { self.set_model_list_expanded(false, ctx); return; } + if self.is_effort_list_expanded { + self.set_effort_list_expanded(false, ctx); + return; + } self.next(ctx); } @@ -1550,6 +1984,9 @@ impl OnboardingSlide for AgentSlide { if self.is_model_list_expanded { self.set_model_list_expanded(false, ctx); } + if self.is_effort_list_expanded { + self.set_effort_list_expanded(false, ctx); + } } } @@ -1561,7 +1998,18 @@ impl TypedActionView for AgentSlide { AgentSlideAction::SelectModel(model_id) => { if !self.agent_settings(ctx).disable_oz { self.select_model(model_id.clone(), ctx); - // If the picker is open, clicking a row collapses it after selecting. + // If either picker is open, clicking a row collapses it after selecting. + if self.is_model_list_expanded { + self.set_model_list_expanded(false, ctx); + } + if self.is_effort_list_expanded { + self.set_effort_list_expanded(false, ctx); + } + } + } + AgentSlideAction::SelectModelGroup(base_title) => { + if !self.agent_settings(ctx).disable_oz { + self.select_model_group(base_title, ctx); if self.is_model_list_expanded { self.set_model_list_expanded(false, ctx); } @@ -1573,6 +2021,12 @@ impl TypedActionView for AgentSlide { } self.set_model_list_expanded(!self.is_model_list_expanded, ctx); } + AgentSlideAction::ToggleEffortListExpanded => { + if self.agent_settings(ctx).disable_oz { + return; + } + self.set_effort_list_expanded(!self.is_effort_list_expanded, ctx); + } AgentSlideAction::HighlightModel(model_id) => { // Only update if the id corresponds to an enabled row. Callers // (hover handlers) already filter this out, but we defend against