diff --git a/app/src/ai/blocklist/action_model/execute/run_agents.rs b/app/src/ai/blocklist/action_model/execute/run_agents.rs index 50947a57a0f..8be5f9b4ff4 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents.rs @@ -617,8 +617,8 @@ fn resolve_request_from_config(request: &mut RunAgentsRequest, config: &Orchestr // The approved plan config is the source of truth for these run-wide fields, // so callers pass a mutable request and continue with the normalized value. let mut config_state = OrchestrationConfigState::from_run_agents_fields( - &request.model_id, - &request.harness_type, + Some(&request.model_id), + Some(&request.harness_type), &request.execution_mode, ); config_state.override_from_approved_config(config); diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index 49cbc2fdff6..c4b1a5306d6 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -25,16 +25,10 @@ use warpui::{ SingletonEntity, SizeConstraint, View, ViewContext, ViewHandle, }; -use crate::ai::auth_secret_types::auth_secret_types_for_harness; use crate::ai::blocklist::inline_action::host_picker::HostPicker; -use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; -use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; use crate::ai::execution_profiles::model_menu_items::available_model_menu_items; -use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel}; +use crate::ai::harness_availability::HarnessAvailabilityModel; use crate::ai::harness_display; -use crate::ai::local_harness_setup::{ - local_harness_is_product_enabled, local_harness_setup_state, LocalHarnessSetupState, -}; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, empty_env_recommendation_message, persist_environment_selection, persist_host_selection, @@ -43,11 +37,11 @@ pub use crate::ai::orchestration::{ OrchestrationConfigState, OrchestrationEditState, ORCHESTRATION_WARP_WORKER_HOST, }; use crate::ai::orchestration::{ - get_base_model_choices, harness_is_selectable, persist_auth_secret_selection, - resolve_recent_host_slug, ORCHESTRATION_ENV_NONE_LABEL, + api_key_snapshot, environment_snapshot, harness_snapshot, host_snapshot, model_snapshot, + persist_auth_secret_selection, OptionBadge, OptionFooter, OptionRow, OptionSnapshot, + OptionSourceStatus, AUTH_SECRET_INHERIT_LABEL, }; use crate::appearance::Appearance; -use crate::cloud_object::CloudObjectLookup as _; use crate::menu::{MenuItem, MenuItemFields}; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; @@ -65,13 +59,9 @@ pub const ORCHESTRATION_PICKER_FONT_SIZE: f32 = 14.; pub const ORCHESTRATION_PICKER_RADIUS: f32 = 4.; pub const ORCHESTRATION_PICKER_MAX_WIDTH: f32 = 205.; -const DEFAULT_MODEL_LABEL: &str = "Default model"; const ORCHESTRATION_SEGMENTED_CONTROL_PADDING: f32 = 4.; const ORCHESTRATION_SEGMENT_VERTICAL_PADDING: f32 = 4.; -/// Label shown in the auth secret picker when no secret is selected -/// (the child agent will inherit credentials from its environment). -const AUTH_SECRET_INHERIT_LABEL: &str = "Skip (advanced)"; /// Label for the auth secret column. pub const AUTH_SECRET_COLUMN_LABEL: &str = "API key"; const AUTH_SECRET_CREATE_NEW_LABEL: &str = "New API key…"; @@ -221,14 +211,66 @@ pub fn new_standard_filterable_picker_dropdown RunAgentsExecutionMode { + if is_local { + RunAgentsExecutionMode::Local + } else { + RunAgentsExecutionMode::Remote { + environment_id: String::new(), + worker_host: String::new(), + computer_use_enabled: false, + } + } +} + +/// Label of the snapshot row matching `selected_id`, if any. +fn selected_row_label(snapshot: &OptionSnapshot) -> Option { + snapshot.selected_id.as_ref().and_then(|id| { + snapshot + .rows + .iter() + .find(|row| &row.id == id) + .map(|row| row.label.clone()) + }) +} + +/// Rich menu items for Oz model rows. The snapshot owns inclusion, +/// ordering, and selection; this maps each row id back to its `LLMInfo` +/// and renders through [`available_model_menu_items`] so Oz rows keep +/// provider/credential icons and disabled gating — GUI rendering +/// concerns that cannot live in the frontend-neutral snapshot layer. +fn oz_model_menu_items( + rows: &[OptionRow], + ctx: &mut ViewContext, +) -> Vec> { + let llm_prefs = LLMPreferences::as_ref(ctx); + let all_choices: Vec<_> = llm_prefs.get_base_llm_choices_for_agent_mode(ctx).collect(); + let ordered_choices: Vec<_> = rows + .iter() + .filter_map(|row| { + all_choices + .iter() + .copied() + .find(|llm| llm.id.to_string() == row.id) + }) + .collect(); + 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, + ) +} + +/// Populates the model picker from [`model_snapshot`] for the active +/// harness (Warp LLM catalog for Oz, "Default model" for local Codex, +/// "Default model" plus the server-provided catalog otherwise). pub fn populate_model_picker_for_harness( dropdown: &ViewHandle>, initial_model_id: &str, @@ -236,201 +278,82 @@ pub fn populate_model_picker_for_harness is_local: bool, ctx: &mut ViewContext, ) { - let initial_model_id = initial_model_id.to_string(); - let harness_type = harness_type.to_string(); + let state = OrchestrationConfigState::from_run_agents_fields( + Some(initial_model_id), + Some(harness_type), + &snapshot_execution_mode(is_local), + ); + let is_oz = matches!( + Harness::parse_orchestration_harness(harness_type), + Some(Harness::Oz) | None + ); dropdown.update(ctx, |dropdown, ctx_dropdown| { - let harness = Harness::parse_orchestration_harness(&harness_type); - 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. - let llm_prefs = LLMPreferences::as_ref(ctx_dropdown); - let (auto_models, rest): (Vec<_>, Vec<_>) = - get_base_model_choices(llm_prefs, ctx_dropdown, is_local) - .partition(|llm| llm.id.as_str().starts_with("auto")); - let (custom_models, other_models): (Vec<_>, Vec<_>) = rest - .into_iter() - .partition(|llm| llm_prefs.custom_llm_info_for_id(&llm.id).is_some()); - let ordered_choices: Vec<_> = auto_models - .into_iter() - .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, - ); - dropdown.set_rich_items(items, ctx_dropdown); - if let Some(name) = &selected_display_name { - dropdown.set_selected_by_name(name, ctx_dropdown); - } - } - Some(Harness::Codex) if is_local => { - // Local Codex: only "Default model" entry. - let items = vec![default_model_menu_item::()]; - dropdown.set_rich_items(items, ctx_dropdown); - dropdown.set_selected_by_name(DEFAULT_MODEL_LABEL, ctx_dropdown); - } - Some(harness) => { - // Non-Oz harness: "Default model" at top, then 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), - )); - items.push(MenuItem::Item(fields)); - } - } - // Find display name before set_rich_items borrows ctx_dropdown mutably. - let selected_display_name = if initial_model_id.is_empty() { - Some(DEFAULT_MODEL_LABEL.to_string()) - } else { - availability - .models_for(harness) - .and_then(|models| { - models - .iter() - .find(|m| m.id == initial_model_id) - .map(|m| m.display_name.clone()) - }) - .or_else(|| Some(DEFAULT_MODEL_LABEL.to_string())) - }; - dropdown.set_rich_items(items, ctx_dropdown); - if let Some(name) = &selected_display_name { - dropdown.set_selected_by_name(name, ctx_dropdown); - } - } + let snapshot = model_snapshot(&state, ctx_dropdown); + let selected_label = selected_row_label(&snapshot); + let items = if is_oz { + oz_model_menu_items::(&snapshot.rows, ctx_dropdown) + } else { + snapshot + .rows + .into_iter() + .map(|row| { + MenuItem::Item(MenuItemFields::new(&row.label).with_on_select_action( + DropdownAction::select_action_and_close(A::model_changed(row.id)), + )) + }) + .collect() + }; + dropdown.set_rich_items(items, ctx_dropdown); + if let Some(label) = &selected_label { + dropdown.set_selected_by_name(label, ctx_dropdown); } }); } -/// Creates a "Default model" menu item that emits an empty model_id. -fn default_model_menu_item() -> MenuItem { - MenuItem::Item( - MenuItemFields::new(DEFAULT_MODEL_LABEL).with_on_select_action( - DropdownAction::select_action_and_close(A::model_changed(String::new())), - ), - ) -} - +/// Populates the harness picker from [`harness_snapshot`], mapping rows +/// to menu items (icon/brand color from the row's harness, disabled +/// reason to a disabled item with a tooltip). pub fn populate_harness_picker( dropdown: &ViewHandle>, initial_harness: &str, is_local: bool, ctx: &mut ViewContext, ) { - let initial_harness = initial_harness.to_string(); + let state = OrchestrationConfigState::from_run_agents_fields( + None, + Some(initial_harness), + &snapshot_execution_mode(is_local), + ); dropdown.update(ctx, |dropdown, ctx_dropdown| { - let availability = HarnessAvailabilityModel::as_ref(ctx_dropdown); - let harnesses = availability.available_harnesses(); - - let resolve_entry_harness = |harness: Harness, display_name: &str| match harness { - Harness::Unknown => [ - Harness::Oz, - Harness::Claude, - Harness::OpenCode, - Harness::Gemini, - Harness::Codex, - ] + let snapshot = harness_snapshot(&state, ctx_dropdown); + let selected_label = selected_row_label(&snapshot); + let items: Vec> = snapshot + .rows .into_iter() - .find(|candidate| harness_display::display_name(*candidate) == display_name) - .unwrap_or(Harness::Unknown), - harness => harness, - }; - - // Sort selectable harnesses before disabled ones, preserving - // relative order within each group. - // Filter out Gemini — it is not yet supported as a multi-agent - // harness and causes an infinite "Spawning agents" hang. - let mut sorted: Vec<_> = harnesses - .iter() - .filter(|entry| { - let harness = resolve_entry_harness(entry.harness, &entry.display_name); - harness != Harness::Gemini - && (!is_local || local_harness_is_product_enabled(harness)) - }) - .collect(); - sorted.sort_by_key(|entry| { - let harness = resolve_entry_harness(entry.harness, &entry.display_name); - !(entry.enabled && harness_is_selectable(harness, is_local)) - }); - - // Resolve the target harness so we can match by enum variant - // even when the `initial_harness` string is "claude" but the - // cached entry.harness deserialized as Unknown. - let target_harness = Harness::parse_orchestration_harness(&initial_harness); - - let mut items: Vec> = Vec::new(); - let mut selected_name: Option = None; - let target_display = target_harness.map(|harness| availability.display_name_for(harness)); - - for entry in sorted { - let harness = resolve_entry_harness(entry.harness, &entry.display_name); - let local_setup_state = if is_local { - Some(local_harness_setup_state(harness)) - } else { - None - }; - // Use the server-provided display_name for the label so stale - // cache entries (where harness deserializes as Unknown) still - // show the correct name. - let mut fields = MenuItemFields::new(&entry.display_name) - .with_icon(harness_display::icon_for(harness)); - if let Some(color) = harness_display::brand_color(harness) { - fields = fields.with_override_icon_color(Fill::from(color)); - } - let harness_str = harness.to_string(); - let selectable = entry.enabled && harness_is_selectable(harness, is_local); - if selectable { - fields = fields.with_on_select_action(DropdownAction::select_action_and_close( - A::harness_changed(harness_str.clone()), - )); - } else { - fields = fields.with_disabled(true); - let tooltip = match local_setup_state { - Some(LocalHarnessSetupState::MissingHarness { tooltip }) => tooltip, - Some(LocalHarnessSetupState::ProductDisabled { message }) => message, - Some(LocalHarnessSetupState::Ready) | None => "Disabled by your administrator", - }; - fields = fields.with_tooltip(tooltip); - } - // Match by harness string first, then fall back to matching - // the display_name against the client-side name for the target - // harness. This handles stale cache entries where entry.harness - // is Unknown but entry.display_name is still correct. - if selected_name.is_none() { - if harness_str.eq_ignore_ascii_case(&initial_harness) { - selected_name = Some(entry.display_name.clone()); - } else if let Some(target_display) = target_display { - if entry.display_name == target_display { - selected_name = Some(entry.display_name.clone()); + .map(|row| { + let mut fields = MenuItemFields::new(&row.label); + if let Some(harness) = row.harness { + fields = fields.with_icon(harness_display::icon_for(harness)); + if let Some(color) = harness_display::brand_color(harness) { + fields = fields.with_override_icon_color(Fill::from(color)); } } - } - items.push(MenuItem::Item(fields)); - } + match row.disabled_reason { + Some(reason) => { + fields = fields.with_disabled(true).with_tooltip(reason); + } + None => { + fields = fields.with_on_select_action( + DropdownAction::select_action_and_close(A::harness_changed(row.id)), + ); + } + } + MenuItem::Item(fields) + }) + .collect(); dropdown.set_rich_items(items, ctx_dropdown); - if let Some(name) = selected_name { - dropdown.set_selected_by_name(&name, ctx_dropdown); + if let Some(label) = &selected_label { + dropdown.set_selected_by_name(label, ctx_dropdown); } }); } @@ -460,84 +383,42 @@ pub fn create_environment_picker( move |app| render_new_environment_footer::(footer_mouse_state.clone(), app), ctx_dropdown, ); - let all_envs = CloudAmbientAgentEnvironment::get_all(ctx_dropdown); - let mut sorted_envs: Vec<(String, String)> = all_envs - .iter() - .map(|env| (env.id.uid(), env.model().string_model.name.clone())) - .collect(); - sorted_envs.sort_by(|a, b| a.1.cmp(&b.1)); - - let mut items: Vec> = Vec::new(); - let mut selected_name: Option = None; - items.push(MenuItem::Item( - MenuItemFields::new(ORCHESTRATION_ENV_NONE_LABEL).with_on_select_action( - DropdownAction::select_action_and_close(A::environment_changed(String::new())), - ), - )); - if initial_env.is_empty() { - selected_name = Some(ORCHESTRATION_ENV_NONE_LABEL.to_string()); - } - for (env_id, env_name) in &sorted_envs { - if env_id == &initial_env { - selected_name = Some(env_name.clone()); - } - let env_id_for_item = env_id.clone(); - items.push(MenuItem::Item( - MenuItemFields::new(env_name).with_on_select_action( - DropdownAction::select_action_and_close(A::environment_changed( - env_id_for_item, - )), - ), - )); - } - dropdown.set_rich_items(items, ctx_dropdown); - if let Some(name) = selected_name { - dropdown.set_selected_by_name(&name, ctx_dropdown); - } }); + populate_environment_picker(&dropdown_handle, &initial_env, ctx); dropdown_handle } +/// Populates the environment picker from [`environment_snapshot`] +/// ("Empty environment" plus existing environments sorted by name). pub fn populate_environment_picker( dropdown_handle: &ViewHandle>, initial_env_id: &str, ctx: &mut ViewContext, ) { - let initial_env = initial_env_id.to_string(); + let state = OrchestrationConfigState::from_run_agents_fields( + None, + None, + &RunAgentsExecutionMode::Remote { + environment_id: initial_env_id.to_string(), + worker_host: String::new(), + computer_use_enabled: false, + }, + ); dropdown_handle.update(ctx, |dropdown, ctx_dropdown| { - let all_envs = CloudAmbientAgentEnvironment::get_all(ctx_dropdown); - let mut sorted_envs: Vec<(String, String)> = all_envs - .iter() - .map(|env| (env.id.uid(), env.model().string_model.name.clone())) + let snapshot = environment_snapshot(&state, ctx_dropdown); + let selected_label = selected_row_label(&snapshot); + let items = snapshot + .rows + .into_iter() + .map(|row| { + MenuItem::Item(MenuItemFields::new(&row.label).with_on_select_action( + DropdownAction::select_action_and_close(A::environment_changed(row.id)), + )) + }) .collect(); - sorted_envs.sort_by(|a, b| a.1.cmp(&b.1)); - - let mut items: Vec> = Vec::new(); - let mut selected_name: Option = None; - items.push(MenuItem::Item( - MenuItemFields::new(ORCHESTRATION_ENV_NONE_LABEL).with_on_select_action( - DropdownAction::select_action_and_close(A::environment_changed(String::new())), - ), - )); - if initial_env.is_empty() { - selected_name = Some(ORCHESTRATION_ENV_NONE_LABEL.to_string()); - } - for (env_id, env_name) in &sorted_envs { - if env_id == &initial_env { - selected_name = Some(env_name.clone()); - } - let env_id_for_item = env_id.clone(); - items.push(MenuItem::Item( - MenuItemFields::new(env_name).with_on_select_action( - DropdownAction::select_action_and_close(A::environment_changed( - env_id_for_item, - )), - ), - )); - } dropdown.set_rich_items(items, ctx_dropdown); - if let Some(name) = selected_name { - dropdown.set_selected_by_name(&name, ctx_dropdown); + if let Some(label) = &selected_label { + dropdown.set_selected_by_name(label, ctx_dropdown); } }); } @@ -591,37 +472,65 @@ fn render_new_environment_footer( .with_cursor(Cursor::PointingHand) .finish() } -/// Repopulates the host picker with the workspace default (if any) and -/// the user's last-selected custom host (if any), then sets the current -/// selection to `initial_host`. +/// Repopulates the host picker rows from [`host_snapshot`] (workspace +/// default, connected workers, recent custom slug), then sets the +/// current selection to `initial_host`. pub fn populate_host_picker( picker: &ViewHandle, initial_host: &str, ctx: &mut ViewContext, ) { - let default_host = resolve_default_host_slug(ctx); - let recent_host = resolve_recent_host_slug(ctx); - let initial = if initial_host.trim().is_empty() { - ORCHESTRATION_WARP_WORKER_HOST.to_string() - } else { - initial_host.to_string() - }; - let mut connected_hosts = ConnectedSelfHostedWorkersModel::as_ref(ctx) - .worker_hosts_excluding(default_host.as_deref()); - connected_hosts.sort(); - connected_hosts.dedup(); + let state = OrchestrationConfigState::from_run_agents_fields( + None, + None, + &RunAgentsExecutionMode::Remote { + environment_id: String::new(), + worker_host: initial_host.to_string(), + computer_use_enabled: false, + }, + ); + let snapshot = host_snapshot(&state, ctx); + let selected = snapshot + .selected_id + .unwrap_or_else(|| ORCHESTRATION_WARP_WORKER_HOST.to_string()); + let mut default_host = None; + let mut recent_host = None; + let mut connected_hosts = Vec::new(); + for row in snapshot.rows { + match row.badge { + Some(OptionBadge::Default) => default_host = Some(row.id), + Some(OptionBadge::Recent) => recent_host = Some(row.id), + Some(OptionBadge::Connected) => connected_hosts.push(row.id), + // The unbadged "warp" row is built into the HostPicker itself. + None => {} + } + } picker.update(ctx, |picker, picker_ctx| { picker.set_options(default_host, recent_host, connected_hosts, picker_ctx); - picker.set_selected(&initial, picker_ctx); + picker.set_selected(&selected, picker_ctx); }); } -// ── Auth secret helpers ──────────────────────────────────────────── +// ── Auth secret helpers ────────────────────────────────── + +/// Trigger label for the auth-secret dropdown. `Unset` falls back to +/// "+ New API key…" rather than auto-picking the first loaded key. +fn auth_secret_trigger_label(selection: &AuthSecretSelection, supports_create_new: bool) -> String { + match selection { + AuthSecretSelection::Named(name) => name.clone(), + AuthSecretSelection::Inherit => AUTH_SECRET_INHERIT_LABEL.to_string(), + AuthSecretSelection::CreatingNew => AUTH_SECRET_CREATE_NEW_LABEL.to_string(), + AuthSecretSelection::Unset if supports_create_new => { + AUTH_SECRET_CREATE_NEW_LABEL.to_string() + } + AuthSecretSelection::Unset => AUTH_SECRET_INHERIT_LABEL.to_string(), + } +} -/// Populates the auth secret picker: Inherit, loaded managed secrets, then -/// a "+ New API key…" entry for harnesses with managed-secret types. Also -/// kicks off a lazy fetch so subsequent paints replace "Loading…" with -/// real entries. +/// Populates the auth secret picker from [`api_key_snapshot`]: Inherit, +/// loaded managed secrets, then a "+ New API key…" entry for harnesses +/// with managed-secret types. Also kicks off a lazy fetch so subsequent +/// paints replace "Loading…" with real entries. pub fn populate_auth_secret_picker_for_harness( dropdown: &ViewHandle>, selection: &AuthSecretSelection, @@ -639,51 +548,37 @@ pub fn populate_auth_secret_picker_for_harness Some(name.clone()), - _ => None, - }; - let supports_create_new = !auth_secret_types_for_harness(harness).is_empty(); - let selection = selection.clone(); + let mut state = OrchestrationConfigState::from_run_agents_fields( + None, + Some(harness_type), + &RunAgentsExecutionMode::Local, + ); + state.auth_secret_selection = selection.clone(); dropdown.update(ctx, |dropdown, ctx_dropdown| { - let availability = HarnessAvailabilityModel::as_ref(ctx_dropdown); - let mut items: Vec> = Vec::new(); - - items.push(MenuItem::Item( - MenuItemFields::new(AUTH_SECRET_INHERIT_LABEL).with_on_select_action( - DropdownAction::select_action_and_close(A::auth_secret_changed(None)), - ), - )); - - let mut selected_display_name: Option = None; - match availability.auth_secrets_for(harness) { - AuthSecretFetchState::Loaded(secrets) => { - for secret in secrets { - let name = secret.name.clone(); - if Some(&name) == initial.as_ref() { - selected_display_name = Some(name.clone()); - } - items.push(MenuItem::Item( - MenuItemFields::new(&name).with_on_select_action( - DropdownAction::select_action_and_close(A::auth_secret_changed(Some( - name.clone(), - ))), - ), - )); - } - } - AuthSecretFetchState::NotFetched | AuthSecretFetchState::Loading => { - items.push(MenuItem::Item( - MenuItemFields::new("Loading…").with_disabled(true), - )); - } - AuthSecretFetchState::Failed(_) => { - items.push(MenuItem::Item( - MenuItemFields::new("Unable to load secrets").with_disabled(true), - )); - } + let snapshot = api_key_snapshot(&state, ctx_dropdown); + let supports_create_new = + matches!(snapshot.footer, Some(OptionFooter::CreateNewAuthSecret)); + let mut items: Vec> = snapshot + .rows + .into_iter() + .map(|row| { + // An empty row id is the Inherit entry; others are named + // managed secrets. + let name = (!row.id.is_empty()).then_some(row.id); + MenuItem::Item(MenuItemFields::new(&row.label).with_on_select_action( + DropdownAction::select_action_and_close(A::auth_secret_changed(name)), + )) + }) + .collect(); + match snapshot.status { + OptionSourceStatus::Loading => items.push(MenuItem::Item( + MenuItemFields::new("Loading…").with_disabled(true), + )), + OptionSourceStatus::Failed { message } => items.push(MenuItem::Item( + MenuItemFields::new(&message).with_disabled(true), + )), + OptionSourceStatus::Ready | OptionSourceStatus::Empty { .. } => {} } - if supports_create_new { items.push(MenuItem::Separator); items.push(MenuItem::Item( @@ -692,22 +587,8 @@ pub fn populate_auth_secret_picker_for_harness name.clone(), - AuthSecretSelection::Inherit => AUTH_SECRET_INHERIT_LABEL.to_string(), - AuthSecretSelection::CreatingNew => AUTH_SECRET_CREATE_NEW_LABEL.to_string(), - AuthSecretSelection::Unset if supports_create_new => { - AUTH_SECRET_CREATE_NEW_LABEL.to_string() - } - AuthSecretSelection::Unset => AUTH_SECRET_INHERIT_LABEL.to_string(), - }; - let _ = selected_display_name; - let _ = &availability; - + let final_selection = + auth_secret_trigger_label(&state.auth_secret_selection, supports_create_new); dropdown.set_rich_items(items, ctx_dropdown); dropdown.set_selected_by_name(&final_selection, ctx_dropdown); }); @@ -871,36 +752,12 @@ pub fn sync_picker_selections( ctx: &mut ViewContext, ) { if let Some(model_picker) = handles.model_picker.clone() { - let target_model_id = state.model_id.clone(); - let harness_type = state.harness_type.clone(); - model_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); - 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()) - } - 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()) - }) - } - } - }; - if let Some(name) = &display_name { - dropdown.set_selected_by_name(name, ctx_dropdown); - } - }); + let snapshot = model_snapshot(state, ctx); + if let Some(label) = selected_row_label(&snapshot) { + model_picker.update(ctx, |dropdown, ctx_dropdown| { + dropdown.set_selected_by_name(&label, ctx_dropdown); + }); + } } if let Some(harness_picker) = handles.harness_picker.clone() { let harness_type = state.harness_type.clone(); @@ -915,20 +772,12 @@ pub fn sync_picker_selections( }); } if let Some(environment_picker) = handles.environment_picker.clone() { - let env_id = match &state.execution_mode { - RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.clone(), - RunAgentsExecutionMode::Local => String::new(), - }; - environment_picker.update(ctx, |dropdown, ctx_dropdown| { - if env_id.is_empty() { - dropdown.set_selected_by_name(ORCHESTRATION_ENV_NONE_LABEL, ctx_dropdown); - return; - } - let all_envs = CloudAmbientAgentEnvironment::get_all(ctx_dropdown); - if let Some(env) = all_envs.iter().find(|e| e.id.uid() == env_id) { - dropdown.set_selected_by_name(&env.model().string_model.name, ctx_dropdown); - } - }); + let snapshot = environment_snapshot(state, ctx); + if let Some(label) = selected_row_label(&snapshot) { + environment_picker.update(ctx, |dropdown, ctx_dropdown| { + dropdown.set_selected_by_name(&label, ctx_dropdown); + }); + } } if let Some(host_picker) = handles.host_picker.clone() { let worker_host = current_worker_host(state).to_string(); @@ -937,21 +786,12 @@ pub fn sync_picker_selections( }); } if let Some(auth_secret_picker) = handles.auth_secret_picker.clone() { - let selection = state.auth_secret_selection.clone(); - let supports_create_new = Harness::parse_orchestration_harness(&state.harness_type) - .filter(|h| *h != Harness::Oz) - .map(|h| !auth_secret_types_for_harness(h).is_empty()) - .unwrap_or(false); + let supports_create_new = matches!( + api_key_snapshot(state, ctx).footer, + Some(OptionFooter::CreateNewAuthSecret) + ); + let label = auth_secret_trigger_label(&state.auth_secret_selection, supports_create_new); auth_secret_picker.update(ctx, |dropdown, ctx_dropdown| { - let label = match &selection { - AuthSecretSelection::Named(name) => name.clone(), - AuthSecretSelection::Inherit => AUTH_SECRET_INHERIT_LABEL.to_string(), - AuthSecretSelection::CreatingNew => AUTH_SECRET_CREATE_NEW_LABEL.to_string(), - AuthSecretSelection::Unset if supports_create_new => { - AUTH_SECRET_CREATE_NEW_LABEL.to_string() - } - AuthSecretSelection::Unset => AUTH_SECRET_INHERIT_LABEL.to_string(), - }; dropdown.set_selected_by_name(&label, ctx_dropdown); }); } 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 1fc19081268..341222a74d9 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 @@ -118,8 +118,8 @@ pub struct RunAgentsEditState { impl RunAgentsEditState { pub fn from_request(req: &RunAgentsRequest) -> Self { let mut orchestration_config_state = oc::OrchestrationConfigState::from_run_agents_fields( - &req.model_id, - &req.harness_type, + Some(&req.model_id), + Some(&req.harness_type), &req.execution_mode, ); // Carry the request's auth secret across the round trip. Absence diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs index 1eec292747e..70fc7e29b96 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs @@ -44,8 +44,8 @@ fn make_config_state_with_orch_fields( let request = make_request(harness, mode); RunAgentsEditState { orchestration_config_state: OrchestrationConfigState::from_run_agents_fields( - &request.model_id, - &request.harness_type, + Some(&request.model_id), + Some(&request.harness_type), &request.execution_mode, ), card: RunAgentsCardFields { diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index a78b4ec2601..f53ba99d355 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -175,8 +175,8 @@ impl OrchestrationConfigBlockView { .unwrap_or_else(|| { ( OrchestrationConfigState::from_run_agents_fields( - "auto", - "oz", + Some("auto"), + Some("oz"), &RunAgentsExecutionMode::Local, ), false, diff --git a/app/src/ai/orchestration/config_state.rs b/app/src/ai/orchestration/config_state.rs index 8b98cf71f7b..a914c3a444a 100644 --- a/app/src/ai/orchestration/config_state.rs +++ b/app/src/ai/orchestration/config_state.rs @@ -85,14 +85,16 @@ impl OrchestrationConfigState { } } + /// `None` (or an empty string wrapped in `Some`) leaves the field + /// unset, matching the wire encoding where absence is emptiness. pub fn from_run_agents_fields( - model_id: &str, - harness_type: &str, + model_id: Option<&str>, + harness_type: Option<&str>, execution_mode: &RunAgentsExecutionMode, ) -> Self { Self { - model_id: model_id.to_string(), - harness_type: harness_type.to_string(), + model_id: model_id.unwrap_or_default().to_string(), + harness_type: harness_type.unwrap_or_default().to_string(), execution_mode: execution_mode.clone(), auth_secret_selection: AuthSecretSelection::Unset, } diff --git a/app/src/ai/orchestration/config_state_tests.rs b/app/src/ai/orchestration/config_state_tests.rs index 88f93bc75ed..0b5c9d0de0d 100644 --- a/app/src/ai/orchestration/config_state_tests.rs +++ b/app/src/ai/orchestration/config_state_tests.rs @@ -14,8 +14,8 @@ fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig { #[test] fn toggle_to_local_sanitizes_disabled_codex() { let mut state = OrchestrationConfigState::from_run_agents_fields( - "gpt-5", - "codex", + Some("gpt-5"), + Some("codex"), &RunAgentsExecutionMode::Remote { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), @@ -36,8 +36,8 @@ fn toggle_to_local_sanitizes_disabled_codex() { #[test] fn toggle_to_local_preserves_claude() { let mut state = OrchestrationConfigState::from_run_agents_fields( - "sonnet", - "claude", + Some("sonnet"), + Some("claude"), &RunAgentsExecutionMode::Remote { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), @@ -57,8 +57,11 @@ fn toggle_to_local_preserves_claude() { #[test] fn resolve_from_config_preserves_local_claude() { - let mut state = - OrchestrationConfigState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local); + let mut state = OrchestrationConfigState::from_run_agents_fields( + None, + None, + &RunAgentsExecutionMode::Local, + ); state.resolve_from_config(&local_config("claude", "sonnet")); assert_eq!(state.harness_type, "claude"); @@ -71,8 +74,11 @@ fn resolve_from_config_preserves_local_claude() { #[test] fn resolve_from_config_sanitizes_disabled_local_codex() { - let mut state = - OrchestrationConfigState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local); + let mut state = OrchestrationConfigState::from_run_agents_fields( + None, + None, + &RunAgentsExecutionMode::Local, + ); state.resolve_from_config(&local_config("codex", "gpt-5")); diff --git a/app/src/ai/orchestration/edit_state_tests.rs b/app/src/ai/orchestration/edit_state_tests.rs index 9a48272e282..23e27d99640 100644 --- a/app/src/ai/orchestration/edit_state_tests.rs +++ b/app/src/ai/orchestration/edit_state_tests.rs @@ -19,8 +19,11 @@ fn model_valid_among<'a>(valid: &'a [&'a str]) -> impl Fn(&str, &str, bool) -> b #[test] fn execution_mode_change_to_local_forces_oz_and_strips_cloud_fields() { - let mut state = - OrchestrationConfigState::from_run_agents_fields("gpt-5", "codex", &remote_mode()); + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some("gpt-5"), + Some("codex"), + &remote_mode(), + ); state.apply_execution_mode_change_core(false, None, None, &model_valid_among(&[""]), &|_| { Some(String::new()) @@ -37,8 +40,8 @@ fn execution_mode_change_to_local_forces_oz_and_strips_cloud_fields() { #[test] fn execution_mode_change_to_cloud_prefills_default_environment() { let mut state = OrchestrationConfigState::from_run_agents_fields( - "auto", - "oz", + Some("auto"), + Some("oz"), &RunAgentsExecutionMode::Local, ); @@ -60,8 +63,8 @@ fn execution_mode_change_to_cloud_prefills_default_environment() { #[test] fn execution_mode_change_prefers_valid_fallback_over_default_model() { let mut state = OrchestrationConfigState::from_run_agents_fields( - "stale", - "oz", + Some("stale"), + Some("oz"), &RunAgentsExecutionMode::Local, ); @@ -78,8 +81,11 @@ fn execution_mode_change_prefers_valid_fallback_over_default_model() { #[test] fn harness_change_saves_and_restores_per_harness_model_memory() { - let state = - OrchestrationConfigState::from_run_agents_fields("sonnet", "claude", &remote_mode()); + let state = OrchestrationConfigState::from_run_agents_fields( + Some("sonnet"), + Some("claude"), + &remote_mode(), + ); let mut edit_state = OrchestrationEditState { orchestration_config_state: state, saved_model_per_harness: HashMap::from([("codex".to_string(), "gpt-5".to_string())]), @@ -104,7 +110,8 @@ fn harness_change_saves_and_restores_per_harness_model_memory() { #[test] fn harness_change_applies_resolved_auth_selection() { - let mut state = OrchestrationConfigState::from_run_agents_fields("auto", "oz", &remote_mode()); + let mut state = + OrchestrationConfigState::from_run_agents_fields(Some("auto"), Some("oz"), &remote_mode()); state.auth_secret_selection = AuthSecretSelection::Named("old-key".to_string()); let mut edit_state = OrchestrationEditState::new(state); @@ -124,8 +131,11 @@ fn harness_change_applies_resolved_auth_selection() { #[test] fn revalidate_drops_deleted_named_secret_and_reseeds_from_resolved() { - let mut state = - OrchestrationConfigState::from_run_agents_fields("sonnet", "claude", &remote_mode()); + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some("sonnet"), + Some("claude"), + &remote_mode(), + ); state.auth_secret_selection = AuthSecretSelection::Named("deleted-key".to_string()); state.revalidate_after_catalog_change_core( @@ -143,8 +153,11 @@ fn revalidate_drops_deleted_named_secret_and_reseeds_from_resolved() { #[test] fn revalidate_keeps_named_secret_still_present() { - let mut state = - OrchestrationConfigState::from_run_agents_fields("sonnet", "claude", &remote_mode()); + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some("sonnet"), + Some("claude"), + &remote_mode(), + ); state.auth_secret_selection = AuthSecretSelection::Named("my-key".to_string()); state.revalidate_after_catalog_change_core( @@ -162,8 +175,11 @@ fn revalidate_keeps_named_secret_still_present() { #[test] fn revalidate_leaves_explicit_inherit_alone() { - let mut state = - OrchestrationConfigState::from_run_agents_fields("sonnet", "claude", &remote_mode()); + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some("sonnet"), + Some("claude"), + &remote_mode(), + ); state.auth_secret_selection = AuthSecretSelection::Inherit; state.revalidate_after_catalog_change_core( @@ -178,8 +194,11 @@ fn revalidate_leaves_explicit_inherit_alone() { #[test] fn revalidate_resets_vanished_model_to_default() { - let mut state = - OrchestrationConfigState::from_run_agents_fields("gone", "claude", &remote_mode()); + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some("gone"), + Some("claude"), + &remote_mode(), + ); state.revalidate_after_catalog_change_core( None, diff --git a/app/src/ai/orchestration/mod.rs b/app/src/ai/orchestration/mod.rs index 79842b42510..d61bf5dd6f2 100644 --- a/app/src/ai/orchestration/mod.rs +++ b/app/src/ai/orchestration/mod.rs @@ -9,25 +9,32 @@ mod config_state; mod edit_state; mod providers; +mod snapshots; mod validation; pub use config_state::{AuthSecretSelection, OrchestrationConfigState}; pub use edit_state::OrchestrationEditState; +#[cfg_attr(not(feature = "tui"), allow(unused_imports))] +pub use providers::ORCHESTRATION_ENV_NONE_LABEL; pub(crate) use providers::{ - can_execute_with_auth_secret, get_base_model_choices, persist_auth_secret_selection, + can_execute_with_auth_secret, persist_auth_secret_selection, populate_default_auth_secret_for_execution, }; pub use providers::{ persist_environment_selection, persist_host_selection, resolve_auth_secret_selection_for_harness, resolve_default_environment_id, - resolve_default_host_slug, resolve_recent_host_slug, ORCHESTRATION_ENV_NONE_LABEL, - ORCHESTRATION_WARP_WORKER_HOST, + resolve_default_host_slug, ORCHESTRATION_WARP_WORKER_HOST, }; -// Consumed by the TUI via `tui_export`; the GUI gates Accept through -// `accept_disabled_reason_with_auth` instead. #[cfg_attr(not(feature = "tui"), allow(unused_imports))] -pub use validation::auth_secret_selection_required; +pub use snapshots::location_snapshot; +pub(crate) use snapshots::AUTH_SECRET_INHERIT_LABEL; +pub use snapshots::{ + api_key_snapshot, environment_snapshot, harness_snapshot, host_snapshot, model_snapshot, + OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus, +}; pub use validation::{ - accept_disabled_reason_with_auth, empty_env_recommendation_message, harness_is_selectable, + accept_disabled_reason_with_auth, empty_env_recommendation_message, should_show_auth_secret_picker, }; +#[cfg_attr(not(feature = "tui"), allow(unused_imports))] +pub use validation::{auth_secret_selection_required, harness_is_selectable}; diff --git a/app/src/ai/orchestration/snapshots.rs b/app/src/ai/orchestration/snapshots.rs new file mode 100644 index 00000000000..0afe94f49d4 --- /dev/null +++ b/app/src/ai/orchestration/snapshots.rs @@ -0,0 +1,577 @@ +//! Plain-data option lists for the orchestration configuration fields: +//! location, harness, model, API key, host, and environment. One builder +//! per field turns the live catalogs into an [`OptionSnapshot`] — rows, +//! ordering, badges, disabled reasons, load state, and selection — and +//! both frontends render their pickers/pages from that snapshot, so the +//! option lists cannot drift between the GUI and the TUI. + +use ai::agent::action::RunAgentsExecutionMode; +use warp_cli::agent::Harness; +use warpui::{AppContext, SingletonEntity}; + +use super::config_state::{AuthSecretSelection, OrchestrationConfigState}; +use super::providers::{ + get_base_model_choices, resolve_default_host_slug, resolve_recent_host_slug, + ORCHESTRATION_ENV_NONE_LABEL, ORCHESTRATION_WARP_WORKER_HOST, +}; +use crate::ai::auth_secret_types::auth_secret_types_for_harness; +use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; +use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; +use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel}; +use crate::ai::harness_display; +use crate::ai::local_harness_setup::{ + local_harness_is_product_enabled, local_harness_setup_state, LocalHarnessSetupState, +}; +use crate::cloud_object::CloudObjectLookup as _; +use crate::LLMPreferences; + +const DEFAULT_MODEL_LABEL: &str = "Default model"; +/// Label shown in the auth secret picker when no secret is selected +/// (the child agent will inherit credentials from its environment). +pub(crate) const AUTH_SECRET_INHERIT_LABEL: &str = "Skip (advanced)"; +const CUSTOM_HOST_LABEL: &str = "Custom host…"; +const AUTH_SECRETS_LOAD_FAILED_MESSAGE: &str = "Unable to load secrets"; + +/// Row id for the Cloud location option. +#[cfg_attr(not(feature = "tui"), allow(dead_code))] +pub const LOCATION_CLOUD_ID: &str = "cloud"; +/// Row id for the Local location option. +#[cfg_attr(not(feature = "tui"), allow(dead_code))] +pub const LOCATION_LOCAL_ID: &str = "local"; + +/// One selectable row in an option snapshot. Carries no GUI types. +#[derive(Debug, Clone, PartialEq)] +pub struct OptionRow { + pub id: String, + pub label: String, + /// Harness identifier for rows representing harnesses; each frontend + /// maps it to its own icon (GUI `Icon`) or glyph/color (TUI). + pub harness: Option, + pub badge: Option, + pub disabled_reason: Option, +} + +impl OptionRow { + /// Creates an enabled row with no badge or harness. + fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + harness: None, + badge: None, + disabled_reason: None, + } + } +} + +/// Secondary marker rendered next to a row's label. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OptionBadge { + Default, + Recent, + Connected, +} + +/// Load state of the catalog backing a snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OptionSourceStatus { + Ready, + Loading, + Failed { message: String }, + Empty { message: String }, +} + +/// Trailing affordance below the option list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OptionFooter { + /// Free-form text entry (e.g. custom host slug). + CustomText { label: String }, + /// "New API key…" affordance. The GUI renders it for harnesses that + /// support managed secrets; the TUI intentionally omits resource creation. + CreateNewAuthSecret, +} + +/// A complete option list for one configuration field. +#[derive(Debug, Clone, PartialEq)] +pub struct OptionSnapshot { + pub rows: Vec, + pub selected_id: Option, + pub status: OptionSourceStatus, + pub footer: Option, +} + +impl OptionSnapshot { + /// A `Ready` snapshot with no footer. + fn ready(rows: Vec, selected_id: Option) -> Self { + Self { + rows, + selected_id, + status: OptionSourceStatus::Ready, + footer: None, + } + } +} + +// ── Location ──────────────────────────────────────────────────────── + +/// Builds the Cloud/Local location options with the current mode selected. +// Only the TUI renders a location page (via `tui_export`); the GUI has +// its own Cloud/Local mode toggle. +#[cfg_attr(not(feature = "tui"), allow(dead_code))] +pub fn location_snapshot(state: &OrchestrationConfigState, _ctx: &AppContext) -> OptionSnapshot { + let rows = vec![ + OptionRow::new(LOCATION_CLOUD_ID, "Cloud"), + OptionRow::new(LOCATION_LOCAL_ID, "Local"), + ]; + let selected = if state.execution_mode.is_remote() { + LOCATION_CLOUD_ID + } else { + LOCATION_LOCAL_ID + }; + OptionSnapshot::ready(rows, Some(selected.to_string())) +} + +// ── Harness ───────────────────────────────────────────────────────── + +/// Server-provided harness entry decoupled from `HarnessAvailability` so +/// the pure row builder can be tested directly. +struct HarnessEntryInput { + harness: Harness, + display_name: String, + enabled: bool, +} + +/// Builds the harness options, mirroring the GUI's +/// `populate_harness_picker` filtering, ordering, disabled reasons, and +/// selection matching. +pub fn harness_snapshot(state: &OrchestrationConfigState, ctx: &AppContext) -> OptionSnapshot { + let is_local = !state.execution_mode.is_remote(); + let availability = HarnessAvailabilityModel::as_ref(ctx); + let entries: Vec = availability + .available_harnesses() + .iter() + .map(|entry| HarnessEntryInput { + harness: entry.harness, + display_name: entry.display_name.clone(), + enabled: entry.enabled, + }) + .collect(); + let target_display = Harness::parse_orchestration_harness(&state.harness_type) + .map(|harness| availability.display_name_for(harness).to_string()); + build_harness_snapshot( + entries, + &state.harness_type, + target_display, + is_local, + &local_harness_setup_state, + ) +} + +/// Pure core of [`harness_snapshot`]. `setup_state_for` is injected so +/// tests don't depend on locally installed CLIs. +fn build_harness_snapshot( + entries: Vec, + initial_harness: &str, + target_display: Option, + is_local: bool, + setup_state_for: &dyn Fn(Harness) -> LocalHarnessSetupState, +) -> OptionSnapshot { + let resolve_entry_harness = |harness: Harness, display_name: &str| match harness { + Harness::Unknown => [ + Harness::Oz, + Harness::Claude, + Harness::OpenCode, + Harness::Gemini, + Harness::Codex, + ] + .into_iter() + .find(|candidate| harness_display::display_name(*candidate) == display_name) + .unwrap_or(Harness::Unknown), + harness => harness, + }; + let setup_is_ready = |harness: Harness| !is_local || setup_state_for(harness).is_selectable(); + + // Sort selectable harnesses before disabled ones, preserving + // relative order within each group. + // Filter out Gemini — it is not yet supported as a multi-agent + // harness and causes an infinite "Spawning agents" hang. + let mut sorted: Vec<_> = entries + .iter() + .filter(|entry| { + let harness = resolve_entry_harness(entry.harness, &entry.display_name); + harness != Harness::Gemini && (!is_local || local_harness_is_product_enabled(harness)) + }) + .collect(); + sorted.sort_by_key(|entry| { + let harness = resolve_entry_harness(entry.harness, &entry.display_name); + !(entry.enabled && setup_is_ready(harness)) + }); + + let mut rows: Vec = Vec::new(); + let mut selected_id: Option = None; + for entry in sorted { + let harness = resolve_entry_harness(entry.harness, &entry.display_name); + let harness_str = harness.to_string(); + let selectable = entry.enabled && setup_is_ready(harness); + let disabled_reason = if selectable { + None + } else { + let local_setup_state = if is_local { + Some(setup_state_for(harness)) + } else { + None + }; + Some( + match local_setup_state { + Some(LocalHarnessSetupState::MissingHarness { tooltip }) => tooltip, + Some(LocalHarnessSetupState::ProductDisabled { message }) => message, + Some(LocalHarnessSetupState::Ready) | None => "Disabled by your administrator", + } + .to_string(), + ) + }; + // Match by harness string first, then fall back to matching + // the display_name against the client-side name for the target + // harness. This handles stale cache entries where entry.harness + // is Unknown but entry.display_name is still correct. + if selected_id.is_none() { + if harness_str.eq_ignore_ascii_case(initial_harness) { + selected_id = Some(harness_str.clone()); + } else if let Some(target_display) = &target_display { + if &entry.display_name == target_display { + selected_id = Some(harness_str.clone()); + } + } + } + rows.push(OptionRow { + id: harness_str, + // Use the server-provided display_name for the label so stale + // cache entries (where harness deserializes as Unknown) still + // show the correct name. + label: entry.display_name.clone(), + harness: Some(harness), + badge: None, + disabled_reason, + }); + } + if rows.is_empty() { + return OptionSnapshot { + rows, + selected_id, + status: OptionSourceStatus::Empty { + message: "No harnesses available".to_string(), + }, + footer: None, + }; + } + OptionSnapshot::ready(rows, selected_id) +} + +// ── Model ─────────────────────────────────────────────────────────── + +/// A model choice already resolved to plain strings. +struct ModelChoiceInput { + id: String, + label: String, + disabled_reason: Option, +} + +/// Builds the model options for the active harness, mirroring the GUI's +/// `populate_model_picker_for_harness` three catalog branches: +/// - **Oz / empty**: the Warp LLM catalog (auto, then custom, then other +/// models; custom models excluded for Cloud runs). +/// - **Local Codex**: only a "Default model" entry. +/// - **Other non-Oz harnesses**: "Default model" plus the server-provided +/// harness model catalog. +pub fn model_snapshot(state: &OrchestrationConfigState, ctx: &AppContext) -> OptionSnapshot { + let is_local = !state.execution_mode.is_remote(); + let harness = Harness::parse_orchestration_harness(&state.harness_type); + 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. + let llm_prefs = LLMPreferences::as_ref(ctx); + let (auto_models, rest): (Vec<_>, Vec<_>) = + get_base_model_choices(llm_prefs, ctx, is_local) + .partition(|llm| llm.id.as_str().starts_with("auto")); + let (custom_models, other_models): (Vec<_>, Vec<_>) = rest + .into_iter() + .partition(|llm| llm_prefs.custom_llm_info_for_id(&llm.id).is_some()); + let choices: Vec = auto_models + .into_iter() + .chain(custom_models) + .chain(other_models) + .map(|llm| ModelChoiceInput { + id: llm.id.to_string(), + label: llm.menu_display_name(), + disabled_reason: llm + .disable_reason + .as_ref() + .map(|reason| reason.tooltip_text().to_string()), + }) + .collect(); + build_oz_model_snapshot(choices, &state.model_id) + } + Some(Harness::Codex) if is_local => { + // Local Codex: only "Default model" entry. + OptionSnapshot::ready( + vec![OptionRow::new(String::new(), DEFAULT_MODEL_LABEL)], + Some(String::new()), + ) + } + Some(harness) => { + let models = HarnessAvailabilityModel::as_ref(ctx) + .models_for(harness) + .map(|models| { + models + .iter() + .map(|model| ModelChoiceInput { + id: model.id.clone(), + label: model.display_name.clone(), + disabled_reason: None, + }) + .collect::>() + }); + build_non_oz_model_snapshot(models, &state.model_id) + } + } +} + +/// Pure core for the Oz / unset branch of [`model_snapshot`]. +fn build_oz_model_snapshot( + choices: Vec, + initial_model_id: &str, +) -> OptionSnapshot { + let selected_id = choices + .iter() + .find(|choice| choice.id == initial_model_id) + .map(|choice| choice.id.clone()); + let rows: Vec = choices + .into_iter() + .map(|choice| OptionRow { + disabled_reason: choice.disabled_reason, + ..OptionRow::new(choice.id, choice.label) + }) + .collect(); + if rows.is_empty() { + return OptionSnapshot { + rows, + selected_id, + status: OptionSourceStatus::Empty { + message: "No models available".to_string(), + }, + footer: None, + }; + } + OptionSnapshot::ready(rows, selected_id) +} + +/// Pure core for the non-Oz branch of [`model_snapshot`]: "Default model" +/// on top, then server-provided models. Unknown or empty selections fall +/// back to "Default model" (empty id). +fn build_non_oz_model_snapshot( + models: Option>, + initial_model_id: &str, +) -> OptionSnapshot { + let mut rows = vec![OptionRow::new(String::new(), DEFAULT_MODEL_LABEL)]; + let mut found_initial = false; + for model in models.into_iter().flatten() { + if model.id == initial_model_id { + found_initial = true; + } + rows.push(OptionRow::new(model.id, model.label)); + } + let selected_id = if !initial_model_id.is_empty() && found_initial { + Some(initial_model_id.to_string()) + } else { + Some(String::new()) + }; + OptionSnapshot::ready(rows, selected_id) +} + +// ── API key ───────────────────────────────────────────────────────── + +/// Fetch state reduced to plain data for the pure API-key builder. +enum AuthSecretNamesInput { + NotLoaded, + Loaded(Vec), + Failed, +} + +/// Builds the API-key options: "Skip (advanced)" (inherit) plus loaded +/// managed-secret names. Secret values are never included — names only. +/// Status mirrors `AuthSecretFetchState`; the `CreateNewAuthSecret` +/// footer is emitted for harnesses with managed-secret types. +pub fn api_key_snapshot(state: &OrchestrationConfigState, ctx: &AppContext) -> OptionSnapshot { + let Some(harness) = Harness::parse_orchestration_harness(&state.harness_type) else { + return OptionSnapshot::ready(Vec::new(), None); + }; + if harness == Harness::Oz { + return OptionSnapshot::ready(Vec::new(), None); + } + let names = match HarnessAvailabilityModel::as_ref(ctx).auth_secrets_for(harness) { + AuthSecretFetchState::Loaded(secrets) => { + AuthSecretNamesInput::Loaded(secrets.iter().map(|s| s.name.clone()).collect()) + } + AuthSecretFetchState::NotFetched | AuthSecretFetchState::Loading => { + AuthSecretNamesInput::NotLoaded + } + AuthSecretFetchState::Failed(_) => AuthSecretNamesInput::Failed, + }; + let supports_create_new = !auth_secret_types_for_harness(harness).is_empty(); + build_api_key_snapshot(names, &state.auth_secret_selection, supports_create_new) +} + +/// Pure core of [`api_key_snapshot`]. +fn build_api_key_snapshot( + names: AuthSecretNamesInput, + selection: &AuthSecretSelection, + supports_create_new: bool, +) -> OptionSnapshot { + let mut rows = vec![OptionRow::new(String::new(), AUTH_SECRET_INHERIT_LABEL)]; + let status = match names { + AuthSecretNamesInput::Loaded(names) => { + for name in names { + rows.push(OptionRow::new(name.clone(), name)); + } + OptionSourceStatus::Ready + } + AuthSecretNamesInput::NotLoaded => OptionSourceStatus::Loading, + AuthSecretNamesInput::Failed => OptionSourceStatus::Failed { + message: AUTH_SECRETS_LOAD_FAILED_MESSAGE.to_string(), + }, + }; + // The selection derives directly from the edit state. `Named` is kept + // even while the catalog is loading so a transient refresh never + // clears it; `Unset`/`CreatingNew` have no selected row. + let selected_id = match selection { + AuthSecretSelection::Named(name) => Some(name.clone()), + AuthSecretSelection::Inherit => Some(String::new()), + AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew => None, + }; + OptionSnapshot { + rows, + selected_id, + status, + footer: supports_create_new.then_some(OptionFooter::CreateNewAuthSecret), + } +} + +// ── Host ──────────────────────────────────────────────────────────── + +/// Builds the host options in the GUI host picker's order: workspace +/// default (badged), warp, connected worker hosts (badged), the recent +/// custom slug (badged), then a custom-host text-entry footer. +pub fn host_snapshot(state: &OrchestrationConfigState, ctx: &AppContext) -> OptionSnapshot { + let default_host = resolve_default_host_slug(ctx); + let recent_host = resolve_recent_host_slug(ctx); + let mut connected_hosts = ConnectedSelfHostedWorkersModel::as_ref(ctx) + .worker_hosts_excluding(default_host.as_deref()); + connected_hosts.sort(); + connected_hosts.dedup(); + let current = match &state.execution_mode { + RunAgentsExecutionMode::Remote { worker_host, .. } if !worker_host.trim().is_empty() => { + worker_host.trim().to_string() + } + RunAgentsExecutionMode::Remote { .. } | RunAgentsExecutionMode::Local => { + ORCHESTRATION_WARP_WORKER_HOST.to_string() + } + }; + build_host_snapshot(default_host, recent_host, connected_hosts, ¤t) +} + +/// Pure core of [`host_snapshot`]. +fn build_host_snapshot( + default_host: Option, + recent_host: Option, + connected_hosts: Vec, + current: &str, +) -> OptionSnapshot { + let mut rows: Vec = Vec::new(); + let mut added_slugs: Vec = Vec::new(); + if let Some(slug) = default_host.filter(|s| !s.trim().is_empty()) { + rows.push(OptionRow { + badge: Some(OptionBadge::Default), + ..OptionRow::new(slug.clone(), slug.clone()) + }); + added_slugs.push(slug); + } + rows.push(OptionRow::new( + ORCHESTRATION_WARP_WORKER_HOST, + ORCHESTRATION_WARP_WORKER_HOST, + )); + added_slugs.push(ORCHESTRATION_WARP_WORKER_HOST.to_string()); + + for slug in connected_hosts { + if slug.trim().is_empty() + || added_slugs + .iter() + .any(|known| known.eq_ignore_ascii_case(&slug)) + { + continue; + } + rows.push(OptionRow { + badge: Some(OptionBadge::Connected), + ..OptionRow::new(slug.clone(), slug.clone()) + }); + added_slugs.push(slug); + } + // `recent_host` already comes from the persisted last selection. Only add + // its row when the same slug was not emitted from a higher-priority source. + if let Some(slug) = recent_host.filter(|s| !s.trim().is_empty()) { + if !added_slugs + .iter() + .any(|known| known.eq_ignore_ascii_case(&slug)) + { + rows.push(OptionRow { + badge: Some(OptionBadge::Recent), + ..OptionRow::new(slug.clone(), slug) + }); + } + } + OptionSnapshot { + rows, + selected_id: Some(current.to_string()), + status: OptionSourceStatus::Ready, + footer: Some(OptionFooter::CustomText { + label: CUSTOM_HOST_LABEL.to_string(), + }), + } +} + +// ── Environment ───────────────────────────────────────────────────── + +/// Builds the environment options: "Empty environment" plus existing +/// environments sorted by name, mirroring the GUI environment picker. +pub fn environment_snapshot(state: &OrchestrationConfigState, ctx: &AppContext) -> OptionSnapshot { + let all_envs = CloudAmbientAgentEnvironment::get_all(ctx); + let mut sorted_envs: Vec<(String, String)> = all_envs + .iter() + .map(|env| (env.id.uid(), env.model().string_model.name.clone())) + .collect(); + sorted_envs.sort_by(|a, b| a.1.cmp(&b.1)); + let current = match &state.execution_mode { + RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + build_environment_snapshot(sorted_envs, ¤t) +} + +/// Pure core of [`environment_snapshot`]; `envs` must already be sorted +/// by display name. +fn build_environment_snapshot(envs: Vec<(String, String)>, current: &str) -> OptionSnapshot { + let mut rows = vec![OptionRow::new(String::new(), ORCHESTRATION_ENV_NONE_LABEL)]; + let mut selected_id = current.is_empty().then(String::new); + for (env_id, env_name) in envs { + if env_id == current { + selected_id = Some(env_id.clone()); + } + rows.push(OptionRow::new(env_id, env_name)); + } + OptionSnapshot::ready(rows, selected_id) +} + +#[cfg(test)] +#[path = "snapshots_tests.rs"] +mod tests; diff --git a/app/src/ai/orchestration/snapshots_tests.rs b/app/src/ai/orchestration/snapshots_tests.rs new file mode 100644 index 00000000000..c1ed0cf853b --- /dev/null +++ b/app/src/ai/orchestration/snapshots_tests.rs @@ -0,0 +1,279 @@ +use warp_cli::agent::Harness; + +use super::{ + build_api_key_snapshot, build_environment_snapshot, build_harness_snapshot, + build_host_snapshot, build_non_oz_model_snapshot, build_oz_model_snapshot, + AuthSecretNamesInput, HarnessEntryInput, ModelChoiceInput, OptionBadge, OptionFooter, + OptionSourceStatus, AUTH_SECRET_INHERIT_LABEL, DEFAULT_MODEL_LABEL, +}; +use crate::ai::local_harness_setup::LocalHarnessSetupState; +use crate::ai::orchestration::config_state::AuthSecretSelection; + +fn entry(harness: Harness, display_name: &str, enabled: bool) -> HarnessEntryInput { + HarnessEntryInput { + harness, + display_name: display_name.to_string(), + enabled, + } +} + +fn all_ready(_harness: Harness) -> LocalHarnessSetupState { + LocalHarnessSetupState::Ready +} + +// ── Harness ───────────────────────────────────────────────────────── + +#[test] +fn harness_snapshot_excludes_gemini_and_selects_initial() { + let entries = vec![ + entry(Harness::Oz, "Warp", true), + entry(Harness::Claude, "Claude Code", true), + entry(Harness::Gemini, "Gemini", true), + ]; + + let snapshot = build_harness_snapshot(entries, "claude", None, false, &all_ready); + + let ids: Vec<&str> = snapshot.rows.iter().map(|r| r.id.as_str()).collect(); + assert!(!ids.contains(&"gemini")); + assert_eq!(snapshot.selected_id.as_deref(), Some("claude")); + assert_eq!(snapshot.status, OptionSourceStatus::Ready); + assert!(snapshot.rows.iter().all(|r| r.harness.is_some())); +} + +#[test] +fn harness_snapshot_filters_product_disabled_local_harness() { + let entries = vec![ + entry(Harness::Oz, "Warp", true), + entry(Harness::Codex, "Codex", true), + ]; + + // Local Codex is product-disabled (feature flag off in tests). + let snapshot = build_harness_snapshot(entries, "oz", None, true, &all_ready); + + let ids: Vec<&str> = snapshot.rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["oz"]); +} + +#[test] +fn harness_snapshot_keeps_cloud_opencode_selectable() { + let entries = vec![ + entry(Harness::Oz, "Warp", true), + entry(Harness::OpenCode, "OpenCode", true), + ]; + + let snapshot = build_harness_snapshot(entries, "oz", None, false, &all_ready); + + let opencode = snapshot + .rows + .iter() + .find(|r| r.id == "opencode") + .expect("OpenCode row present on Cloud"); + // The harness list doesn't disable OpenCode; the accept gate does. + assert_eq!(opencode.disabled_reason, None); +} + +#[test] +fn harness_snapshot_marks_missing_local_cli_disabled_and_sorts_last() { + let entries = vec![ + entry(Harness::Claude, "Claude Code", true), + entry(Harness::Oz, "Warp", true), + ]; + let setup = |harness: Harness| match harness { + Harness::Claude => LocalHarnessSetupState::MissingHarness { + tooltip: "Install Claude Code to use this local harness.", + }, + Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Codex | Harness::Unknown => { + LocalHarnessSetupState::Ready + } + }; + + let snapshot = build_harness_snapshot(entries, "oz", None, true, &setup); + + let ids: Vec<&str> = snapshot.rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["oz", "claude"]); + assert_eq!( + snapshot.rows[1].disabled_reason.as_deref(), + Some("Install Claude Code to use this local harness.") + ); +} + +#[test] +fn harness_snapshot_marks_server_disabled_entries() { + let entries = vec![ + entry(Harness::Oz, "Warp", true), + entry(Harness::Claude, "Claude Code", false), + ]; + + let snapshot = build_harness_snapshot(entries, "oz", None, false, &all_ready); + + assert_eq!( + snapshot.rows[1].disabled_reason.as_deref(), + Some("Disabled by your administrator") + ); +} + +#[test] +fn harness_snapshot_matches_selection_by_display_name_for_stale_cache() { + // Stale cache: harness deserialized as Unknown but display_name intact. + let entries = vec![entry(Harness::Unknown, "Claude Code", true)]; + + let snapshot = build_harness_snapshot( + entries, + "claude", + Some("Claude Code".to_string()), + false, + &all_ready, + ); + + assert_eq!(snapshot.selected_id.as_deref(), Some("claude")); +} + +// ── Model ─────────────────────────────────────────────────────────── + +fn model(id: &str, label: &str) -> ModelChoiceInput { + ModelChoiceInput { + id: id.to_string(), + label: label.to_string(), + disabled_reason: None, + } +} + +#[test] +fn oz_model_snapshot_empty_catalog_reports_empty_status() { + let snapshot = build_oz_model_snapshot(Vec::new(), "auto"); + assert!(matches!(snapshot.status, OptionSourceStatus::Empty { .. })); +} +/// Disabled model metadata remains available to every snapshot consumer. +#[test] +fn oz_model_snapshot_carries_disabled_reason() { + let mut disabled_model = model("unavailable", "Unavailable"); + disabled_model.disabled_reason = Some("This model is unavailable.".to_string()); + + let snapshot = build_oz_model_snapshot(vec![disabled_model], ""); + + assert_eq!( + snapshot.rows[0].disabled_reason.as_deref(), + Some("This model is unavailable.") + ); +} + +#[test] +fn non_oz_model_snapshot_puts_default_first_and_selects_server_model() { + let snapshot = build_non_oz_model_snapshot( + Some(vec![model("opus", "Opus"), model("sonnet", "Sonnet")]), + "sonnet", + ); + + assert_eq!(snapshot.rows[0].label, DEFAULT_MODEL_LABEL); + assert_eq!(snapshot.rows[0].id, ""); + assert_eq!(snapshot.selected_id.as_deref(), Some("sonnet")); +} + +#[test] +fn non_oz_model_snapshot_falls_back_to_default_for_unknown_or_empty_id() { + for initial in ["", "gone"] { + let snapshot = build_non_oz_model_snapshot(Some(vec![model("opus", "Opus")]), initial); + assert_eq!(snapshot.selected_id.as_deref(), Some("")); + } + // No server catalog at all: only the Default model row. + let snapshot = build_non_oz_model_snapshot(None, ""); + assert_eq!(snapshot.rows.len(), 1); + assert_eq!(snapshot.selected_id.as_deref(), Some("")); +} + +// ── API key ───────────────────────────────────────────────────────── + +#[test] +fn api_key_snapshot_lists_skip_then_names() { + let snapshot = build_api_key_snapshot( + AuthSecretNamesInput::Loaded(vec!["key-a".to_string(), "key-b".to_string()]), + &AuthSecretSelection::Named("key-b".to_string()), + true, + ); + + let labels: Vec<&str> = snapshot.rows.iter().map(|r| r.label.as_str()).collect(); + assert_eq!(labels, vec![AUTH_SECRET_INHERIT_LABEL, "key-a", "key-b"]); + assert_eq!(snapshot.selected_id.as_deref(), Some("key-b")); + assert_eq!(snapshot.status, OptionSourceStatus::Ready); + assert_eq!(snapshot.footer, Some(OptionFooter::CreateNewAuthSecret)); +} + +#[test] +fn api_key_snapshot_keeps_named_selection_while_loading() { + let snapshot = build_api_key_snapshot( + AuthSecretNamesInput::NotLoaded, + &AuthSecretSelection::Named("my-key".to_string()), + true, + ); + assert_eq!(snapshot.selected_id.as_deref(), Some("my-key")); +} + +#[test] +fn api_key_snapshot_maps_inherit_and_unset_selection() { + let inherit = build_api_key_snapshot( + AuthSecretNamesInput::Loaded(vec![]), + &AuthSecretSelection::Inherit, + true, + ); + assert_eq!(inherit.selected_id.as_deref(), Some("")); + + let unset = build_api_key_snapshot( + AuthSecretNamesInput::Loaded(vec![]), + &AuthSecretSelection::Unset, + true, + ); + assert_eq!(unset.selected_id, None); +} + +// ── Host ──────────────────────────────────────────────────────────── + +#[test] +fn host_snapshot_orders_default_warp_connected_recent() { + let snapshot = build_host_snapshot( + Some("team-default".to_string()), + Some("recent-host".to_string()), + vec!["worker-1".to_string()], + "warp", + ); + + let ids: Vec<&str> = snapshot.rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["team-default", "warp", "worker-1", "recent-host"]); + assert_eq!(snapshot.rows[0].badge, Some(OptionBadge::Default)); + assert_eq!(snapshot.rows[2].badge, Some(OptionBadge::Connected)); + assert_eq!(snapshot.rows[3].badge, Some(OptionBadge::Recent)); + assert_eq!(snapshot.selected_id.as_deref(), Some("warp")); + assert!(matches!( + snapshot.footer, + Some(OptionFooter::CustomText { .. }) + )); +} + +#[test] +fn host_snapshot_dedupes_connected_and_recent_against_known_rows() { + let snapshot = build_host_snapshot( + Some("team-default".to_string()), + Some("team-default".to_string()), + vec!["warp".to_string(), "team-default".to_string()], + "team-default", + ); + + let ids: Vec<&str> = snapshot.rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["team-default", "warp"]); +} + +// ── Environment ───────────────────────────────────────────────────── + +#[test] +fn environment_snapshot_puts_empty_option_first() { + let snapshot = build_environment_snapshot( + vec![ + ("env-a".to_string(), "Alpha".to_string()), + ("env-b".to_string(), "Beta".to_string()), + ], + "env-b", + ); + + assert_eq!(snapshot.rows[0].id, ""); + assert_eq!(snapshot.rows[0].label, super::ORCHESTRATION_ENV_NONE_LABEL); + assert_eq!(snapshot.selected_id.as_deref(), Some("env-b")); +} diff --git a/app/src/ai/orchestration/validation.rs b/app/src/ai/orchestration/validation.rs index 4520e0bd51e..49264da3691 100644 --- a/app/src/ai/orchestration/validation.rs +++ b/app/src/ai/orchestration/validation.rs @@ -16,6 +16,7 @@ use crate::cloud_object::CloudObjectLookup as _; /// Whether a harness's local setup allows selecting it: always true for /// Cloud, otherwise requires the local CLI to be installed and the /// harness to be product-enabled. +#[cfg_attr(not(feature = "tui"), allow(dead_code))] pub(crate) fn local_harness_setup_is_ready(harness: Harness, is_local: bool) -> bool { !is_local || local_harness_setup_state(harness).is_selectable() } @@ -24,6 +25,9 @@ pub(crate) fn local_harness_setup_is_ready(harness: Harness, is_local: bool) -> /// Gemini (not yet supported for multi-agent runs), product-disabled /// local harnesses, and local harnesses whose CLI setup is not ready. /// Both frontends must filter/disable identically through this predicate. +// Only the TUI consumes this predicate directly (via `tui_export`); the +// GUI filters through the harness snapshot builder, which mirrors it. +#[cfg_attr(not(feature = "tui"), allow(dead_code))] pub fn harness_is_selectable(harness: Harness, is_local: bool) -> bool { if harness == Harness::Gemini { return false; diff --git a/app/src/ai/orchestration/validation_tests.rs b/app/src/ai/orchestration/validation_tests.rs index 473db648107..0f63e839d71 100644 --- a/app/src/ai/orchestration/validation_tests.rs +++ b/app/src/ai/orchestration/validation_tests.rs @@ -24,7 +24,8 @@ fn state( mode: RunAgentsExecutionMode, auth: AuthSecretSelection, ) -> OrchestrationConfigState { - let mut state = OrchestrationConfigState::from_run_agents_fields("auto", harness, &mode); + let mut state = + OrchestrationConfigState::from_run_agents_fields(Some("auto"), Some(harness), &mode); state.auth_secret_selection = auth; state } diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 748c7e47cc1..cd151f8f2bf 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -65,11 +65,13 @@ pub use crate::ai::conversation_export::{ pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; pub use crate::ai::orchestration::{ - accept_disabled_reason_with_auth, auth_secret_selection_required, - empty_env_recommendation_message, harness_is_selectable, persist_environment_selection, - persist_host_selection, resolve_auth_secret_selection_for_harness, - resolve_default_environment_id, resolve_default_host_slug, resolve_recent_host_slug, - should_show_auth_secret_picker, AuthSecretSelection, OrchestrationConfigState, + accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, + empty_env_recommendation_message, environment_snapshot, harness_is_selectable, + harness_snapshot, host_snapshot, location_snapshot, model_snapshot, + persist_environment_selection, persist_host_selection, + resolve_auth_secret_selection_for_harness, resolve_default_environment_id, + resolve_default_host_slug, should_show_auth_secret_picker, AuthSecretSelection, OptionBadge, + OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus, OrchestrationConfigState, OrchestrationEditState, ORCHESTRATION_ENV_NONE_LABEL, ORCHESTRATION_WARP_WORKER_HOST, }; pub use crate::ai::skills::{SkillManager, SkillReference}; diff --git a/specs/code-1822-option-snapshots/TECH.md b/specs/code-1822-option-snapshots/TECH.md new file mode 100644 index 00000000000..c5f30e1eb32 --- /dev/null +++ b/specs/code-1822-option-snapshots/TECH.md @@ -0,0 +1,141 @@ +# TECH: Frontend-neutral orchestration option snapshots + +## Context + +This slice builds on the frontend-neutral orchestration edit-state module +(`app/src/ai/orchestration/`, base commit `ff91958d`; see +`specs/code-1822-edit-state/TECH.md`). At that base, the domain module owns the edit +state, transitions, validation predicates, and catalog providers, but the GUI pickers +still build their option lists inline: + +- `app/src/ai/blocklist/inline_action/orchestration_controls.rs` — the + `populate_model_picker_for_harness`, `populate_harness_picker`, + `populate_environment_picker`, `populate_host_picker`, + `populate_auth_secret_picker_for_harness`, and `sync_picker_selections` bodies read + catalogs (`HarnessAvailabilityModel`, `LLMPreferences`, + `CloudAmbientAgentEnvironment`, `ConnectedSelfHostedWorkersModel`) and translate them + straight into `MenuItem`s, including ordering, filtering, disabled reasons, badges, + and selection matching. The `DEFAULT_MODEL_LABEL` and `AUTH_SECRET_INHERIT_LABEL` + display strings are defined in this GUI file. + +The planned TUI orchestration card must render the exact same option lists — same rows, +order, badges, disabled reasons, load/error states, and selection — without depending on +GUI element types. That requires lifting the catalog-to-rows logic into the domain +module as plain data, and rewriting the GUI pickers to consume it so the logic cannot +drift between frontends. + +## Proposed changes + +### Snapshot contract (`app/src/ai/orchestration/snapshots.rs`) + +Plain-data option lists both frontends render from: + +- `OptionRow` — `id`, `label`, optional `harness: Option` (each frontend maps + it to its own icon or glyph), optional `badge`, optional `disabled_reason`. +- `OptionBadge` — `Default` / `Recent` / `Connected` secondary markers. +- `OptionSourceStatus` — `Ready` / `Loading` / `Failed { message }` / + `Empty { message }` load state of the backing catalog. +- `OptionFooter` — trailing affordance: `CustomText { label }` (custom host slug entry) + or `CreateNewAuthSecret` ("New API key…"; the TUI ignores it since resource creation + is out of scope there). +- `OptionSnapshot` — `rows`, `selected_id: Option`, `status`, `footer`. + +Six builders, one per configuration field, each taking +`(&OrchestrationConfigState, &AppContext)`: + +- `location_snapshot` — Cloud/Local rows (`LOCATION_CLOUD_ID` / `LOCATION_LOCAL_ID`); + only the TUI renders a location page, the GUI keeps its own mode toggle. +- `harness_snapshot` — mirrors the GUI harness picker: filters Gemini and + product-disabled local harnesses, sorts selectable before disabled, resolves stale + `Harness::Unknown` cache entries by display name via `harness_display::display_name`, + and carries `disabled_reason` from `LocalHarnessSetupState` tooltips. +- `model_snapshot` — three catalog branches: Oz/unset uses the Warp LLM catalog + (auto, then custom, then other models; custom models excluded for Cloud runs), local + Codex gets only a "Default model" row, other non-Oz harnesses get "Default model" plus + the server-provided harness model catalog with unknown selections falling back to the + default (empty id). +- `api_key_snapshot` — "Skip (advanced)" inherit row plus loaded managed-secret names + (names only, never values); status mirrors `AuthSecretFetchState`; emits the + `CreateNewAuthSecret` footer when `auth_secret_types_for_harness` is non-empty; keeps + a `Named` selection while the catalog is loading. +- `host_snapshot` — workspace default (badged `Default`), `warp`, connected worker + hosts (badged `Connected`, case-insensitively deduped), the recent custom slug + (badged `Recent`), then a `CustomText` footer. +- `environment_snapshot` — "Empty environment" plus existing environments sorted by + name. + +Each builder is a thin `AppContext`-reading wrapper over a pure core +(`build_harness_snapshot`, `build_oz_model_snapshot`, `build_non_oz_model_snapshot`, +`build_api_key_snapshot`, `build_host_snapshot`, `build_environment_snapshot`) so the +mirroring logic is unit-testable without app singletons; catalog-dependent inputs +(`HarnessEntryInput`, `ModelChoiceInput`, `AuthSecretNamesInput`, setup-state callback) +are injected. + +`DEFAULT_MODEL_LABEL` and `AUTH_SECRET_INHERIT_LABEL` move into this module (the GUI +file's copies are deleted); `AUTH_SECRET_INHERIT_LABEL` is re-exported `pub(crate)` for +the GUI's selected-label rendering. + +`app/src/ai/orchestration/mod.rs` declares `mod snapshots` and re-exports the types and +builders. Items only named by the TUI (`location_snapshot`, `OptionRow`, +`ORCHESTRATION_ENV_NONE_LABEL`, `auth_secret_selection_required`, +`harness_is_selectable`) carry `#[cfg_attr(not(feature = "tui"), allow(unused_imports))]` +so `cargo check` is clean with and without the `tui` feature; the same pattern applies +`allow(dead_code)` to `harness_is_selectable` / `local_harness_setup_is_ready` in +`validation.rs`, which the GUI now reaches only through the harness snapshot builder. + +### GUI adaptation (same diff, behavior-parity gate) + +The GUI pickers are rewritten onto the snapshots in the same PR so the snapshot layer is +proven equivalent by the existing GUI tests, and the inline catalog logic is deleted +rather than duplicated (net-negative in `orchestration_controls.rs`): + +- `app/src/ai/blocklist/inline_action/orchestration_controls.rs` — each `populate_*` + body calls its snapshot builder and translates rows/status/footer into `MenuItem`s; + `sync_picker_selections` re-derives selections from fresh snapshots. Behavior parity + requirements the translation must preserve: + - Oz model rows stay rich: snapshot rows are matched back through + `available_model_menu_items` (`orchestration_controls.rs:283`) so provider and + credential icons render exactly as in the execution-profile model menu; non-Oz rows + render from the snapshot labels directly. + - Harness rows map `OptionRow::harness` through `harness_display::icon_for` and + `harness_display::brand_color` (`orchestration_controls.rs:335`), and + `disabled_reason` becomes the disabled tooltip. + - `OptionFooter::CreateNewAuthSecret` maps to the "New API key…" item, and + `OptionSourceStatus::Loading`/`Failed` map to the disabled placeholder items + (`orchestration_controls.rs:559`-`579`). + - Host badges map `Default`→"(default)", `Connected`→"(connected)", + `Recent`→"(recent)" suffixes, and the `CustomText` footer stays the custom-host + entry row. +- `app/src/ai/blocklist/inline_action/run_agents_card_view.rs`, + `app/src/ai/document/orchestration_config_block.rs`, and + `app/src/ai/blocklist/action_model/execute/run_agents.rs` consume the adapted picker + APIs; all pre-existing GUI tests must pass unchanged. + +### TUI export surface + +`app/src/tui_export.rs` extends the existing `crate::ai::orchestration` re-export block +with `api_key_snapshot`, `environment_snapshot`, `harness_snapshot`, `host_snapshot`, +`location_snapshot`, `model_snapshot`, `OptionBadge`, `OptionFooter`, `OptionRow`, +`OptionSnapshot`, and `OptionSourceStatus`. `resolve_recent_host_slug` drops out of the +block: recent-host data now reaches frontends through `host_snapshot`. + +## Testing and validation + +- `app/src/ai/orchestration/snapshots_tests.rs` (colocated per repo convention) covers + the pure cores: harness filtering/sorting/disabled reasons/stale-cache selection + matching, the three model branches and default fallback, api-key status mapping and + footer emission, host ordering/badging/dedup, and environment sorting/selection. +- Pre-existing GUI tests (`run_agents_card_view_tests.rs`, `run_agents_tests.rs`, + `orchestration_config_block` coverage) must pass unchanged — they are the + behavior-parity gate for the picker rewrite. +- Commands: `cargo check -p warp`, `cargo check -p warp --features tui`, + `cargo nextest run -p warp -E 'test(orchestration) + test(run_agents) + test(run_agents_card_view) + test(orchestration_config_block)'`, + plus `./script/format`. +- Manual: all five pickers on the confirmation card and plan-card config block render + the same rows, icons, badges, disabled tooltips, and loading/error placeholders as + before, and selection syncing after harness/mode changes is unchanged. + +## Follow-ups + +Later PRs render these snapshots in the TUI: the host/environment selector and the TUI +orchestration card consume the builders exported through `tui_export`.