diff --git a/app/src/ai/blocklist/inline_action/host_picker.rs b/app/src/ai/blocklist/inline_action/host_picker.rs new file mode 100644 index 0000000000..7919bf6592 --- /dev/null +++ b/app/src/ai/blocklist/inline_action/host_picker.rs @@ -0,0 +1,511 @@ +//! Picker for the cloud-agent worker host slug. +//! +//! In list mode it shows a dropdown styled to match the other orchestration +//! pickers; in custom mode it swaps the top bar for an inline editor that +//! accepts a self-hosted worker slug. The layout mirrors the Oz webapp's +//! host selector: workspace default first (badged "Default"), then warp, +//! then the user's most recent custom slug, then a "Custom host…" entry. + +use warpui::elements::{ + Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, + Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, + PositionedElementAnchor, Radius, +}; +use warpui::platform::Cursor; +use warpui::{ + AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, +}; + +use warp_core::ui::theme::Fill; + +use crate::ai::blocklist::inline_action::orchestration_controls::{ + self as oc, ORCHESTRATION_PICKER_BORDER_WIDTH, ORCHESTRATION_PICKER_FONT_SIZE, + ORCHESTRATION_PICKER_HEIGHT, ORCHESTRATION_PICKER_RADIUS, ORCHESTRATION_WARP_WORKER_HOST, +}; +use crate::appearance::Appearance; +use crate::editor::{ + EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, + TextOptions, +}; +use crate::menu::{MenuItem, MenuItemFields}; +use crate::ui_components::blended_colors; +use crate::ui_components::icons::Icon; +use crate::view_components::dropdown::{ + Dropdown, DropdownAction, DropdownEvent, DropdownStyle, DROPDOWN_PADDING, +}; + +// ── Public API types ──────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub enum HostPickerEvent { + /// Emitted with a non-empty, trimmed slug whenever the user picks a + /// known host or commits a custom entry. + HostChanged { slug: String }, + /// Emitted when the menu closes or the inline editor blurs, so the + /// parent can refocus its own input. + Closed, +} + +const CUSTOM_HOST_LABEL: &str = "Custom host…"; +const DEFAULT_BADGE: &str = "Default"; +const EDITOR_PLACEHOLDER: &str = "my-worker-host"; + +// ── Internal action plumbing ──────────────────────────────────────── + +/// Dispatched by the inner dropdown items and the inline cancel button. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InternalAction { + /// Pick a known host (warp, workspace default, or a recent slug). + SelectKnown(String), + /// Switch to custom-mode text input. + EnterCustomMode, + /// Exit custom mode without committing the editor contents. + CancelCustom, +} + +// ── View ──────────────────────────────────────────────────────────── + +pub struct HostPicker { + /// The slug that would be sent to the server if dispatched now. + current_slug: String, + /// Admin-configured workspace default, when set. + default_host: Option, + /// User's most-recent custom host, deduped against warp / default. + recent_host: Option, + dropdown: ViewHandle>, + editor: ViewHandle, + clear_mouse_state: MouseStateHandle, + is_custom_mode: bool, + /// Snapshot taken when the editor was opened so cancel can revert. + slug_before_edit: Option, +} + +impl HostPicker { + pub fn new(ctx: &mut ViewContext) -> Self { + let (_styles, colors) = oc::picker_styles(Appearance::as_ref(ctx)); + + // Inner dropdown — styled to match the other orchestration pickers. + let dropdown = ctx.add_typed_action_view(|ctx_dropdown| { + let mut dropdown = Dropdown::::new(ctx_dropdown); + dropdown.set_use_overlay_layer(false, ctx_dropdown); + dropdown.set_main_axis_size(MainAxisSize::Max, ctx_dropdown); + dropdown.set_style(DropdownStyle::ActionButtonSecondary, ctx_dropdown); + dropdown.set_top_bar_height(ORCHESTRATION_PICKER_HEIGHT, ctx_dropdown); + dropdown.set_top_bar_max_width(f32::INFINITY); + dropdown.set_padding(colors.padding, ctx_dropdown); + dropdown.set_border_radius(colors.corner_radius, ctx_dropdown); + dropdown.set_background(colors.background, ctx_dropdown); + dropdown.set_border_width(ORCHESTRATION_PICKER_BORDER_WIDTH, ctx_dropdown); + dropdown.set_font_size(ORCHESTRATION_PICKER_FONT_SIZE, ctx_dropdown); + dropdown + }); + ctx.subscribe_to_view(&dropdown, |me, _, event, ctx| { + if let DropdownEvent::Close = event { + // Don't propagate Closed while transitioning into custom + // mode — the parent would refocus itself, blur the editor + // we just focused, and the resulting commit-on-blur would + // immediately revert us back out of custom mode. + if me.is_custom_mode { + return; + } + ctx.emit(HostPickerEvent::Closed); + ctx.notify(); + } + }); + + let editor = ctx.add_typed_action_view(|ctx_editor| { + let appearance = Appearance::as_ref(ctx_editor); + let mut editor = EditorView::single_line( + SingleLineEditorOptions { + text: TextOptions::ui_text(Some(appearance.ui_font_size()), appearance), + propagate_and_no_op_vertical_navigation_keys: + PropagateAndNoOpNavigationKeys::Always, + select_all_on_focus: true, + ..Default::default() + }, + ctx_editor, + ); + editor.set_placeholder_text(EDITOR_PLACEHOLDER, ctx_editor); + editor + }); + ctx.subscribe_to_view(&editor, |me, _, event, ctx| { + me.handle_editor_event(event, ctx); + }); + + let mut me = Self { + current_slug: ORCHESTRATION_WARP_WORKER_HOST.to_string(), + default_host: None, + recent_host: None, + dropdown, + editor, + clear_mouse_state: MouseStateHandle::default(), + is_custom_mode: false, + slug_before_edit: None, + }; + me.repopulate_menu(ctx); + me.sync_dropdown_selection(ctx); + me + } + + // ── Public API ────────────────────────────────────────────────── + + /// Replaces the default and recent menu rows. Pass `None` to omit one. + pub fn set_options( + &mut self, + default_host: Option, + recent_host: Option, + ctx: &mut ViewContext, + ) { + self.default_host = default_host.filter(|s| !s.trim().is_empty()); + self.recent_host = recent_host.filter(|s| !s.trim().is_empty()); + self.repopulate_menu(ctx); + self.sync_dropdown_selection(ctx); + ctx.notify(); + } + + /// Pass `true` to paint the open menu in the overlay layer (avoids + /// being visually covered by sibling pickers below the host picker). + pub fn set_use_overlay_layer(&mut self, use_overlay_layer: bool, ctx: &mut ViewContext) { + self.dropdown.update(ctx, |dropdown, ctx_dropdown| { + dropdown.set_use_overlay_layer(use_overlay_layer, ctx_dropdown); + }); + } + + /// Anchors the open menu (e.g. flip upward to avoid covering siblings). + pub fn set_menu_position( + &mut self, + element_anchor: PositionedElementAnchor, + child_anchor: ChildAnchor, + ctx: &mut ViewContext, + ) { + self.dropdown.update(ctx, |dropdown, ctx_dropdown| { + dropdown.set_menu_position(element_anchor, child_anchor, ctx_dropdown); + }); + } + + /// Sets the displayed slug. Unknown slugs switch the picker into custom + /// mode pre-filled with the slug. Empty input falls back to `"warp"`. + pub fn set_selected(&mut self, slug: &str, ctx: &mut ViewContext) { + let effective = normalize_slug(slug); + let is_known = self.is_known_option(&effective); + self.current_slug = effective.clone(); + if is_known { + self.is_custom_mode = false; + self.sync_dropdown_selection(ctx); + } else { + self.enter_custom_mode_with_slug(&effective, ctx); + } + ctx.notify(); + } + + // ── Internals ─────────────────────────────────────────────────── + + fn is_known_option(&self, slug: &str) -> bool { + if slug.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { + return true; + } + if self.default_host.as_deref() == Some(slug) { + return true; + } + if self.recent_host.as_deref() == Some(slug) { + return true; + } + false + } + + fn repopulate_menu(&mut self, ctx: &mut ViewContext) { + let items = build_menu_items(self.default_host.as_deref(), self.recent_host.as_deref()); + self.dropdown.update(ctx, |dropdown, ctx_dropdown| { + dropdown.set_rich_items(items, ctx_dropdown); + }); + } + + fn sync_dropdown_selection(&mut self, ctx: &mut ViewContext) { + let label = menu_label_for(&self.current_slug, self.default_host.as_deref()); + self.dropdown.update(ctx, |dropdown, ctx_dropdown| { + dropdown.set_selected_by_name(&label, ctx_dropdown); + }); + } + + fn enter_custom_mode_with_slug(&mut self, slug: &str, ctx: &mut ViewContext) { + self.is_custom_mode = true; + self.slug_before_edit = Some(self.current_slug.clone()); + let initial = if slug.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { + String::new() + } else { + slug.to_string() + }; + self.editor.update(ctx, |editor, editor_ctx| { + editor.set_buffer_text_ignoring_undo(&initial, editor_ctx); + }); + ctx.focus(&self.editor); + } + + /// Commits the editor contents. Empty input reverts to the previous slug. + fn commit_custom(&mut self, ctx: &mut ViewContext) { + // Trim manually here — we must distinguish empty input (revert) from + // a literal "warp" entry (commit). `normalize_slug` collapses both + // into `"warp"`, so it's not safe to use on the commit path. + let raw = self.editor.as_ref(ctx).buffer_text(ctx).trim().to_string(); + if raw.is_empty() { + self.cancel_custom(ctx); + return; + } + if raw.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { + // Treat "warp" typed in custom mode as a normal warp selection. + self.current_slug = ORCHESTRATION_WARP_WORKER_HOST.to_string(); + self.is_custom_mode = false; + self.slug_before_edit = None; + self.sync_dropdown_selection(ctx); + ctx.emit(HostPickerEvent::HostChanged { + slug: self.current_slug.clone(), + }); + ctx.emit(HostPickerEvent::Closed); + ctx.notify(); + return; + } + self.current_slug = raw.clone(); + self.is_custom_mode = false; + self.slug_before_edit = None; + // Promote an unknown slug to the "recent" row so it stays visible + // in the list on the next paint. + if !self.is_known_option(&raw) { + self.recent_host = Some(raw.clone()); + self.repopulate_menu(ctx); + } + self.sync_dropdown_selection(ctx); + ctx.emit(HostPickerEvent::HostChanged { slug: raw }); + ctx.emit(HostPickerEvent::Closed); + ctx.notify(); + } + + fn cancel_custom(&mut self, ctx: &mut ViewContext) { + if !self.is_custom_mode { + return; + } + if let Some(prev) = self.slug_before_edit.take() { + self.current_slug = prev; + } + self.is_custom_mode = false; + self.sync_dropdown_selection(ctx); + ctx.emit(HostPickerEvent::Closed); + ctx.notify(); + } + + fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { + match event { + EditorEvent::Enter => self.commit_custom(ctx), + EditorEvent::Escape => self.cancel_custom(ctx), + EditorEvent::Blurred => { + if self.is_custom_mode { + self.commit_custom(ctx); + } + } + _ => {} + } + } + + fn render_custom_mode(&self, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + let background: Fill = theme.surface_overlay_1(); + let border_color = theme.outline(); + + // Center the editor vertically — without this, the row's tight + // cross-axis constraint stretches it to fill the content height and + // the glyphs render flush to the top. + let centered_editor = Flex::column() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::Center) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child(ChildView::new(&self.editor).finish()) + .finish(); + let cancel_button = self.render_cancel_button(appearance); + + let row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_main_axis_size(MainAxisSize::Max) + .with_child(Expanded::new(1.0, centered_editor).finish()) + .with_child(cancel_button) + .finish(); + + // Mirror the dropdown's outer vertical margin so custom mode + // lines up with the other pickers instead of riding higher. + Container::new( + ConstrainedBox::new( + Container::new(row) + .with_horizontal_padding(12.) + .with_vertical_padding(6.) + .with_background(background) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels( + ORCHESTRATION_PICKER_RADIUS, + ))) + .with_border( + Border::all(ORCHESTRATION_PICKER_BORDER_WIDTH) + .with_border_fill(border_color), + ) + .finish(), + ) + .with_height(ORCHESTRATION_PICKER_HEIGHT) + .finish(), + ) + .with_margin_top(DROPDOWN_PADDING) + .with_margin_bottom(DROPDOWN_PADDING) + .finish() + } + + fn render_cancel_button(&self, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + let icon_fill = Fill::Solid(blended_colors::text_disabled(theme, theme.surface_1())); + let mouse_state = self.clear_mouse_state.clone(); + Hoverable::new(mouse_state, move |_| { + ConstrainedBox::new( + Container::new(Icon::X.to_warpui_icon(icon_fill).finish()) + .with_uniform_padding(2.) + .finish(), + ) + .with_width(16.) + .with_height(16.) + .finish() + }) + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action(InternalAction::CancelCustom); + }) + .with_cursor(Cursor::PointingHand) + .finish() + } +} + +// ── Pure helpers (also exercised by unit tests) ───────────────────── + +/// Trims `slug` and falls back to `"warp"` when empty. +fn normalize_slug(slug: &str) -> String { + let trimmed = slug.trim(); + if trimmed.is_empty() { + ORCHESTRATION_WARP_WORKER_HOST.to_string() + } else { + trimmed.to_string() + } +} + +/// Builds the menu items shown in list mode, in the order: workspace default +/// (badged "Default" if set), warp, recent custom slug (if any and not a +/// duplicate), then a "Custom host…" entry. +pub(crate) fn build_menu_items( + default_host: Option<&str>, + recent_host: Option<&str>, +) -> Vec>> { + let mut items: Vec>> = Vec::new(); + + if let Some(slug) = default_host { + items.push(menu_item_for_known( + slug, + Some(DEFAULT_BADGE), + InternalAction::SelectKnown(slug.to_string()), + )); + } + items.push(menu_item_for_known( + ORCHESTRATION_WARP_WORKER_HOST, + None, + InternalAction::SelectKnown(ORCHESTRATION_WARP_WORKER_HOST.to_string()), + )); + if let Some(slug) = recent_host { + if default_host != Some(slug) && !slug.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) + { + // Recent hosts render as plain slugs; only the workspace + // default carries a badge. + items.push(menu_item_for_known( + slug, + None, + InternalAction::SelectKnown(slug.to_string()), + )); + } + } + items.push(MenuItem::Item( + MenuItemFields::new(CUSTOM_HOST_LABEL).with_on_select_action( + DropdownAction::SelectActionAndClose(InternalAction::EnterCustomMode), + ), + )); + + items +} + +/// Returns the menu label corresponding to `slug`, including the "Default" +/// badge when it matches the workspace default. +pub(crate) fn menu_label_for(slug: &str, default_host: Option<&str>) -> String { + if default_host == Some(slug) { + format_known_label(slug, Some(DEFAULT_BADGE)) + } else { + format_known_label(slug, None) + } +} + +fn format_known_label(slug: &str, badge: Option<&str>) -> String { + match badge { + Some(badge) => format!("{slug} ({badge})"), + None => slug.to_string(), + } +} + +fn menu_item_for_known( + slug: &str, + badge: Option<&str>, + action: InternalAction, +) -> MenuItem> { + MenuItem::Item( + MenuItemFields::new(format_known_label(slug, badge)) + .with_on_select_action(DropdownAction::SelectActionAndClose(action)), + ) +} + +// ── Entity / View impls ───────────────────────────────────────────── + +impl Entity for HostPicker { + type Event = HostPickerEvent; +} + +impl TypedActionView for HostPicker { + type Action = InternalAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + InternalAction::SelectKnown(slug) => { + let slug = slug.clone(); + self.current_slug = slug.clone(); + self.is_custom_mode = false; + self.slug_before_edit = None; + // The inner dropdown already updated its own selection; + // re-entering it here would trigger a circular update. + ctx.emit(HostPickerEvent::HostChanged { slug }); + ctx.notify(); + } + InternalAction::EnterCustomMode => { + let current = self.current_slug.clone(); + self.enter_custom_mode_with_slug(¤t, ctx); + ctx.notify(); + } + InternalAction::CancelCustom => { + self.cancel_custom(ctx); + } + } + } +} + +impl View for HostPicker { + fn ui_name() -> &'static str { + "HostPicker" + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + if self.is_custom_mode { + self.render_custom_mode(appearance) + } else { + ChildView::new(&self.dropdown).finish() + } + } +} + +#[cfg(test)] +#[path = "host_picker_tests.rs"] +mod tests; diff --git a/app/src/ai/blocklist/inline_action/host_picker_tests.rs b/app/src/ai/blocklist/inline_action/host_picker_tests.rs new file mode 100644 index 0000000000..2347419770 --- /dev/null +++ b/app/src/ai/blocklist/inline_action/host_picker_tests.rs @@ -0,0 +1,118 @@ +//! Unit tests for the pure helpers in `host_picker.rs`. View-driven +//! behaviors (custom-mode commit, blur, etc.) are covered by manual smoke +//! testing. + +use super::{ + build_menu_items, menu_label_for, normalize_slug, DropdownAction, InternalAction, MenuItem, + ORCHESTRATION_WARP_WORKER_HOST, +}; + +/// Extracts the visible label text out of a `MenuItem::Item`, panicking +/// on the unreachable `Header` / `Separator` cases that our builder +/// doesn't emit. +fn item_label(item: &MenuItem>) -> &str { + match item { + MenuItem::Item(fields) => fields.label(), + other => panic!("expected MenuItem::Item, got {other:?}"), + } +} + +/// Extracts the on-select action from a `MenuItem::Item`. +fn item_action(item: &MenuItem>) -> &DropdownAction { + match item { + MenuItem::Item(fields) => fields + .on_select_action() + .expect("test items always have a select action"), + other => panic!("expected MenuItem::Item, got {other:?}"), + } +} + +#[test] +fn build_menu_items_with_no_defaults_shows_warp_and_custom() { + let items = build_menu_items(None, None); + assert_eq!(items.len(), 2, "expected warp + custom-host entries"); + assert_eq!(item_label(&items[0]), ORCHESTRATION_WARP_WORKER_HOST); + assert_eq!(item_label(&items[1]), "Custom host\u{2026}"); +} + +#[test] +fn build_menu_items_promotes_default_to_top() { + // Workspace default sits above warp and gets the "Default" badge, + // matching the Oz webapp's HostSelector layout. + let items = build_menu_items(Some("my-corp"), None); + assert_eq!(items.len(), 3); + assert_eq!(item_label(&items[0]), "my-corp (Default)"); + assert_eq!(item_label(&items[1]), ORCHESTRATION_WARP_WORKER_HOST); + assert_eq!(item_label(&items[2]), "Custom host\u{2026}"); +} + +#[test] +fn build_menu_items_adds_recent_after_warp() { + let items = build_menu_items(None, Some("other-host")); + assert_eq!(items.len(), 3); + assert_eq!(item_label(&items[0]), ORCHESTRATION_WARP_WORKER_HOST); + // Recent hosts render as plain slugs (no "(Recent)" suffix). + assert_eq!(item_label(&items[1]), "other-host"); + assert_eq!(item_label(&items[2]), "Custom host\u{2026}"); +} + +#[test] +fn build_menu_items_dedups_recent_when_it_matches_default_or_warp() { + // Same as the workspace default → no duplicate "Recent" row. + let items = build_menu_items(Some("my-corp"), Some("my-corp")); + assert_eq!(items.len(), 3); + assert_eq!(item_label(&items[0]), "my-corp (Default)"); + assert_eq!(item_label(&items[1]), ORCHESTRATION_WARP_WORKER_HOST); + assert_eq!(item_label(&items[2]), "Custom host\u{2026}"); + + // Recent == "warp" is also skipped (warp is already a row). + let items = build_menu_items(Some("my-corp"), Some("warp")); + assert_eq!(items.len(), 3, "warp recent should not double-add"); +} + +#[test] +fn build_menu_items_warp_entry_dispatches_select_known_warp() { + let items = build_menu_items(None, None); + match item_action(&items[0]) { + DropdownAction::SelectActionAndClose(InternalAction::SelectKnown(slug)) => { + assert_eq!(slug, ORCHESTRATION_WARP_WORKER_HOST); + } + other => panic!("expected SelectActionAndClose(SelectKnown), got {other:?}"), + } +} + +#[test] +fn build_menu_items_custom_entry_dispatches_enter_custom_mode() { + let items = build_menu_items(None, None); + let custom = items.last().expect("custom entry is always last"); + match item_action(custom) { + DropdownAction::SelectActionAndClose(InternalAction::EnterCustomMode) => {} + other => panic!("expected EnterCustomMode, got {other:?}"), + } +} + +#[test] +fn menu_label_for_picks_default_badge_when_slug_matches_default() { + let label = menu_label_for("my-corp", Some("my-corp")); + assert_eq!(label, "my-corp (Default)"); +} + +#[test] +fn menu_label_for_returns_plain_slug_for_warp() { + let label = menu_label_for(ORCHESTRATION_WARP_WORKER_HOST, Some("my-corp")); + assert_eq!(label, ORCHESTRATION_WARP_WORKER_HOST); +} + +#[test] +fn menu_label_for_returns_plain_slug_for_unknown_value() { + // A slug typed via custom mode that we haven't promoted to "recent" yet. + let label = menu_label_for("typed-once", Some("my-corp")); + assert_eq!(label, "typed-once"); +} + +#[test] +fn normalize_slug_trims_whitespace_and_falls_back_to_warp_when_empty() { + assert_eq!(normalize_slug(" my-corp "), "my-corp"); + assert_eq!(normalize_slug(""), ORCHESTRATION_WARP_WORKER_HOST); + assert_eq!(normalize_slug(" "), ORCHESTRATION_WARP_WORKER_HOST); +} diff --git a/app/src/ai/blocklist/inline_action/mod.rs b/app/src/ai/blocklist/inline_action/mod.rs index e5e7f8cae2..ce5b40611a 100644 --- a/app/src/ai/blocklist/inline_action/mod.rs +++ b/app/src/ai/blocklist/inline_action/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod ask_user_question_view; pub(super) mod aws_bedrock_credentials_error; pub(crate) mod code_diff_view; pub(crate) mod create_or_edit_document; +pub(crate) mod host_picker; pub(crate) mod inline_action_header; pub(crate) mod inline_action_icons; mod malformed_line_heuristics; diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index 7eac18a3a4..256d493b23 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -28,11 +28,11 @@ use warpui::{ use settings::Setting; use warp_cli::agent::Harness; -use warp_core::channel::{Channel, ChannelState}; use warp_core::features::FeatureFlag; use warp_core::ui::theme::Fill; use crate::ai::auth_secret_types::auth_secret_types_for_harness; +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::execution_profiles::model_menu_items::available_model_menu_items; @@ -47,8 +47,13 @@ use crate::report_if_error; use crate::ui_components::blended_colors; use crate::view_components::dropdown::{Dropdown, DropdownAction, DropdownStyle}; use crate::view_components::FilterableDropdown; +use crate::workspaces::user_workspaces::UserWorkspaces; use crate::LLMPreferences; +/// Env var override for the workspace default host (developer testing). +/// Mirrors the single-agent ambient flow. +const DEFAULT_HOST_ENV_VAR: &str = "WARP_CLOUD_MODE_DEFAULT_HOST"; + // ── Shared constants ──────────────────────────────────────────────── pub const ORCHESTRATION_WARP_WORKER_HOST: &str = "warp"; @@ -78,7 +83,6 @@ pub trait OrchestrationControlAction: Clone + Debug + Send + Sync + 'static { fn model_changed(model_id: String) -> Self; fn harness_changed(harness_type: String) -> Self; fn environment_changed(environment_id: String) -> Self; - fn worker_host_changed(worker_host: String) -> Self; /// Fires when the auth secret picker selects a managed secret. /// `None` means "clear the selection / inherit from environment". fn auth_secret_changed(name: Option) -> Self; @@ -291,7 +295,7 @@ pub struct OrchestrationPickerHandles { pub model_picker: Option>>, pub harness_picker: Option>>, pub environment_picker: Option>>, - pub host_picker: Option>>, + pub host_picker: Option>, /// Picker for the managed auth secret used by non-Oz cloud children. /// `None` when the picker hasn't been built yet (e.g. harness is Oz or /// execution mode is Local), or when the harness has no supported @@ -688,37 +692,72 @@ pub fn create_environment_picker( dropdown_handle } -pub fn populate_host_picker( - dropdown: &ViewHandle>, +/// 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`. +pub fn populate_host_picker( + picker: &ViewHandle, initial_host: &str, ctx: &mut ViewContext, ) { - let initial_host = if initial_host.is_empty() { + 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() }; - dropdown.update(ctx, |dropdown, ctx_dropdown| { - let hosts: &[&str] = if matches!(ChannelState::channel(), Channel::Local) { - &["warp", "local-dev"] - } else { - &["warp"] - }; - let mut items: Vec>> = Vec::new(); - let mut selected_idx = None; - for (idx, &host) in hosts.iter().enumerate() { - let fields = MenuItemFields::new(host).with_on_select_action( - DropdownAction::SelectActionAndClose(A::worker_host_changed(host.to_string())), - ); - if host.eq_ignore_ascii_case(&initial_host) { - selected_idx = Some(idx); - } - items.push(MenuItem::Item(fields)); - } - dropdown.set_rich_items(items, ctx_dropdown); - if let Some(idx) = selected_idx { - dropdown.set_selected_by_index(idx, ctx_dropdown); + picker.update(ctx, |picker, picker_ctx| { + picker.set_options(default_host, recent_host, picker_ctx); + picker.set_selected(&initial, picker_ctx); + }); +} + +/// Resolves the workspace-configured default host slug, honoring the +/// `WARP_CLOUD_MODE_DEFAULT_HOST` env var override for developer +/// testing. Mirrors the single-agent ambient flow. +pub fn resolve_default_host_slug(ctx: &AppContext) -> Option { + if let Ok(slug) = std::env::var(DEFAULT_HOST_ENV_VAR) { + let trimmed = slug.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); } + } + UserWorkspaces::as_ref(ctx) + .default_host_slug() + .map(str::to_string) + .filter(|s| !s.trim().is_empty()) +} + +/// Returns the user's last-selected custom host slug from +/// `CloudAgentSettings.last_selected_host`, excluding `"warp"` and the +/// workspace default (those are surfaced as separate menu rows). +pub fn resolve_recent_host_slug(ctx: &AppContext) -> Option { + let last = CloudAgentSettings::as_ref(ctx) + .last_selected_host + .value() + .clone() + .filter(|s| !s.trim().is_empty())?; + if last.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { + return None; + } + if resolve_default_host_slug(ctx).as_deref() == Some(last.as_str()) { + return None; + } + Some(last) +} + +/// Persists the user's most-recent host selection to +/// `CloudAgentSettings.last_selected_host`. Skipped for `"warp"` and +/// empty values (those don't represent a custom slug worth remembering). +pub fn persist_host_selection(worker_host: &str, ctx: &mut ViewContext) { + let trimmed = worker_host.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { + return; + } + let value = trimmed.to_string(); + CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { + report_if_error!(settings.last_selected_host.set_value(Some(value), ctx)); }); } @@ -1188,8 +1227,8 @@ pub fn sync_picker_selections( RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.clone(), RunAgentsExecutionMode::Local => ORCHESTRATION_WARP_WORKER_HOST.to_string(), }; - host_picker.update(ctx, |dropdown, ctx_dropdown| { - dropdown.set_selected_by_name(&worker_host, ctx_dropdown); + host_picker.update(ctx, |picker, picker_ctx| { + picker.set_selected(&worker_host, picker_ctx); }); } if let Some(auth_secret_picker) = handles.auth_secret_picker.clone() { 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 8f37cabcc9..0ea56358b3 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 @@ -34,6 +34,7 @@ use crate::ai::blocklist::agent_view::orchestration_pill_bar::render_static_agen use crate::ai::blocklist::block::model::AIBlockModel; use crate::ai::blocklist::block::view_impl::WithContentItemSpacing; use crate::ai::blocklist::block::AIBlock; +use crate::ai::blocklist::inline_action::host_picker::{HostPicker, HostPickerEvent}; use crate::ai::blocklist::inline_action::inline_action_header::{HeaderConfig, InteractionMode}; use crate::ai::blocklist::inline_action::inline_action_icons; use crate::ai::blocklist::inline_action::orchestration_controls::{ @@ -146,9 +147,6 @@ impl OrchestrationControlAction for RunAgentsCardViewAction { fn environment_changed(environment_id: String) -> Self { Self::EnvironmentChanged { environment_id } } - fn worker_host_changed(worker_host: String) -> Self { - Self::WorkerHostChanged { worker_host } - } fn auth_secret_changed(auth_secret_name: Option) -> Self { Self::AuthSecretChanged { auth_secret_name } } @@ -254,9 +252,13 @@ fn resolve_interactive_defaults( let needs_host = worker_host.is_empty(); let needs_env = environment_id.is_empty(); if needs_host { - state - .orch - .set_worker_host(oc::ORCHESTRATION_WARP_WORKER_HOST.to_string()); + // Prefer the workspace default (or the dev env-var override) + // over the bare "warp" fallback so self-hosted teams see + // their default pre-selected. Mirrors the Oz webapp's + // `HostSelector` initial-selection behavior. + let default_host = oc::resolve_default_host_slug(ctx) + .unwrap_or_else(|| oc::ORCHESTRATION_WARP_WORKER_HOST.to_string()); + state.orch.set_worker_host(default_host); } if needs_env { if let Some(default_env) = oc::resolve_default_environment_id(ctx) { @@ -668,10 +670,27 @@ impl RunAgentsCardView { RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(), RunAgentsExecutionMode::Local => oc::ORCHESTRATION_WARP_WORKER_HOST, }; - let handle = oc::new_standard_picker_dropdown(&colors, ctx); - Self::set_upward_menu_position(&handle, ctx); + let handle = ctx.add_typed_action_view(HostPicker::new); + // Open upward so the menu doesn't overlap pickers below it, + // matching the other dropdowns in this card. + handle.update(ctx, |picker, picker_ctx| { + picker.set_menu_position( + warpui::elements::PositionedElementAnchor::TopLeft, + warpui::elements::ChildAnchor::BottomLeft, + picker_ctx, + ); + }); oc::populate_host_picker(&handle, initial_host, ctx); - Self::subscribe_picker_close(&handle, ctx); + ctx.subscribe_to_view(&handle, |me, _, event, ctx| match event { + HostPickerEvent::HostChanged { slug } => { + ctx.dispatch_typed_action(&RunAgentsCardViewAction::WorkerHostChanged { + worker_host: slug.clone(), + }); + } + HostPickerEvent::Closed => { + me.refocus_after_picker_close(ctx); + } + }); self.handles.pickers.host_picker = Some(handle); } @@ -920,6 +939,7 @@ impl TypedActionView for RunAgentsCardView { } RunAgentsCardViewAction::WorkerHostChanged { worker_host } => { self.state.orch.set_worker_host(worker_host.clone()); + oc::persist_host_selection(worker_host, ctx); ctx.notify(); } RunAgentsCardViewAction::AuthSecretChanged { auth_secret_name } => { diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index eb1cc29a44..6657e13e25 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -15,6 +15,7 @@ use warpui::platform::Cursor; use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext}; use crate::ai::agent::conversation::AIConversationId; +use crate::ai::blocklist::inline_action::host_picker::{HostPicker, HostPickerEvent}; use crate::ai::blocklist::inline_action::orchestration_controls::{ self as oc, OrchestrationControlAction, OrchestrationEditState, OrchestrationPickerHandles, }; @@ -99,9 +100,6 @@ impl OrchestrationControlAction for OrchestrationConfigBlockAction { fn environment_changed(environment_id: String) -> Self { Self::EnvironmentChanged { environment_id } } - fn worker_host_changed(worker_host: String) -> Self { - Self::WorkerHostChanged { worker_host } - } fn auth_secret_changed(auth_secret_name: Option) -> Self { Self::AuthSecretChanged { auth_secret_name } } @@ -304,8 +302,13 @@ impl OrchestrationConfigBlockView { }; let mut filled_defaults = false; if needs_host { - self.edit_state - .set_worker_host(oc::ORCHESTRATION_WARP_WORKER_HOST.to_string()); + // Prefer the workspace default (or the dev env-var override) + // over the bare "warp" fallback so self-hosted teams see + // their default pre-selected. Mirrors the Oz webapp's + // `HostSelector` initial-selection behavior. + let default_host = oc::resolve_default_host_slug(ctx) + .unwrap_or_else(|| oc::ORCHESTRATION_WARP_WORKER_HOST.to_string()); + self.edit_state.set_worker_host(default_host); filled_defaults = true; } if needs_env { @@ -329,9 +332,20 @@ impl OrchestrationConfigBlockView { RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(), RunAgentsExecutionMode::Local => oc::ORCHESTRATION_WARP_WORKER_HOST, }; - let host_handle = oc::new_standard_picker_dropdown(&colors, ctx); - host_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); + let host_handle = ctx.add_typed_action_view(HostPicker::new); + // Paint the open menu in the overlay layer so it doesn't get covered + // by sibling pickers, matching the other pickers in this view. + host_handle.update(ctx, |picker, picker_ctx| { + picker.set_use_overlay_layer(true, picker_ctx); + }); oc::populate_host_picker(&host_handle, initial_host, ctx); + ctx.subscribe_to_view(&host_handle, |_me, _, event, ctx| { + if let HostPickerEvent::HostChanged { slug } = event { + ctx.dispatch_typed_action(&OrchestrationConfigBlockAction::WorkerHostChanged { + worker_host: slug.clone(), + }); + } + }); self.pickers.host_picker = Some(host_handle); // Seed the auth secret from persisted per-harness settings before @@ -608,6 +622,7 @@ impl TypedActionView for OrchestrationConfigBlockView { } OrchestrationConfigBlockAction::WorkerHostChanged { worker_host } => { self.edit_state.set_worker_host(worker_host.clone()); + oc::persist_host_selection(worker_host, ctx); self.apply_field_change(ctx); ctx.notify(); } diff --git a/specs/QUALITY-701/PRODUCT.md b/specs/QUALITY-701/PRODUCT.md new file mode 100644 index 0000000000..780891c2e5 --- /dev/null +++ b/specs/QUALITY-701/PRODUCT.md @@ -0,0 +1,70 @@ +# Custom Host Picker for Orchestration + +Linear: [QUALITY-701](https://linear.app/warpdotdev/issue/QUALITY-701) + +## Summary + +Adds a host picker to the orchestration UI so a user can choose where their cloud child agents run. Today the host is hardcoded to the default Warp cluster; this lets users target a self-hosted worker host, see the most recently used custom host, and pre-select an admin-configured workspace default. The behavior mirrors the Oz webapp's host selector, adapted to the desktop client's compact picker chrome. + +## Design +No Figma mock. Design context lives in this Slack thread: https://warpdev.slack.com/archives/C0AAMT5TKC2/p1778542414211539?thread_ts=1778525276.139389&cid=C0AAMT5TKC2 — this implementation follows that direction but keeps things deliberately simpler for the first cut (compact dropdown with an inline custom-mode editor, reusing the existing orchestration picker chrome). Design polish can be a follow-up once the feature is in users' hands. + +## Behavior + +### Surface + +1. The host picker appears next to the model, harness, and environment pickers in the orchestration UI. It is present in both the orchestrate confirmation card and the plan-card orchestration block. Both surfaces show the same options, the same selection, and the same custom-mode editor. + +2. The picker is only visible when the execution mode is Cloud (Remote). In Local mode the host concept is not user-facing. + +3. The picker visually matches the other orchestration pickers in the same row: same height, border, corner radius, background, padding, and font. + +### List mode + +4. By default the picker renders as a dropdown showing the currently selected slug. Clicking it opens a menu with the following entries, in this order: + 1. Workspace default slug, when the team has one configured, with a "Default" badge. + 2. `warp` (the default Warp cluster), always present. + 3. The user's most recent custom host slug, when set, rendered as a plain slug (no badge). + 4. A `Custom host…` entry that switches the picker into custom mode. + +5. Duplicate rows are suppressed. If the recent custom host equals either `warp` or the workspace default, it does not get its own row. + +6. When the user picks `warp`, the workspace default, or a recent slug, the picker closes and the selection is sent to the parent. The selected entry shows in the picker's collapsed state. If the workspace has a configured default, selecting it shows the "Default" badge in the collapsed state too. + +7. Clicking outside the open menu, or pressing Escape, closes the menu without changing the selection. + +### Custom mode + +8. Selecting `Custom host…` swaps the picker top bar for an inline text editor, pre-filled with the current slug (or empty when the current slug is `warp`), and focuses the editor. A small cancel button sits at the right of the editor. + +9. Inside the editor the user can type any non-empty slug. Pressing Enter or blurring the editor commits the trimmed value. Pressing Escape or clicking the cancel button reverts to the previous selection without committing. + +10. Committing an empty buffer is treated as a revert (no change to the previous selection). + +11. Typing `warp` (case-insensitive) and committing collapses back to the standard `warp` selection rather than persisting `warp` as a custom value. + +12. When a non-empty, non-`warp` slug is committed, it becomes the current selection and is promoted to the "recent" row in the menu so it stays visible on the next paint. The slug is also persisted (see invariant 17) so it survives across cards and across app restarts. + +13. While the editor is in custom mode, the editor's text is vertically centered within the picker box and the box sits at the same y offset as the other pickers in the row. + +### Selection model + +14. The picker always has a non-empty selection. Empty input from any source (initial state, blur, blank stream) resolves to `warp`. + +15. When an external caller sets a slug that doesn't match any known menu option (warp, workspace default, recent), the picker switches into custom mode pre-filled with that slug instead of showing a missing menu entry. + +16. The picker exposes the workspace default behavior: when a workspace default is configured and no explicit selection exists yet, the picker pre-selects the workspace default rather than `warp`. A developer-only `WARP_CLOUD_MODE_DEFAULT_HOST` environment variable overrides the workspace default for local testing. + +### Persistence and recency + +17. When the user commits a custom slug, it is persisted as the "last selected host" so the next plan card or confirmation card shows it as the "recent" entry. `warp` and empty values are never persisted as recent (the warp entry is always present unconditionally). + +18. The recent slug is deduplicated against the workspace default. If the user's most recent slug happens to equal the workspace default, the menu shows only the default row; no separate recent row appears. + +### Coordination with the rest of the orchestration UI + +19. When the user picks or commits a slug, the new value is reflected in the same edit state that powers the other orchestration pickers, and is used by the eventual `RunAgents` dispatch as the `worker_host` field. The plan card additionally persists the new value to the orchestration config snapshot for that plan. + +20. The picker's open menu paints above sibling pickers in the row so it doesn't visually collide with the Environment or Base model picker rendered below it. In the confirmation card the menu opens upward (matching the other dropdowns in that card); in the plan card the menu paints in an overlay layer above siblings. + +21. When the menu closes (selection, dismissal, or custom-mode commit), parent input focus returns to wherever it was so the user can continue typing without an extra click. diff --git a/specs/QUALITY-701/TECH.md b/specs/QUALITY-701/TECH.md new file mode 100644 index 0000000000..cc8381ea42 --- /dev/null +++ b/specs/QUALITY-701/TECH.md @@ -0,0 +1,132 @@ +# Custom Host Picker — Tech Spec + +Linear: [QUALITY-701](https://linear.app/warpdotdev/issue/QUALITY-701) + +Companion product spec: `specs/QUALITY-701/PRODUCT.md` + +## Context + +The orchestration UI (orchestrate confirmation card and plan-card orchestration block) hosts a row of pickers — model, harness, environment — that drive child agent dispatch. Until now there was no UI to choose the worker host: the dispatched `RunAgents` request always carried `worker_host = "warp"`, which routes to the default Warp cluster. Customers running self-hosted workers had no way to target them from the desktop client; the Oz webapp's host selector is the only existing entry point. + +The picker chrome used by the other orchestration pickers is built around the standard `Dropdown` view in `app/src/view_components/dropdown.rs`, styled via `picker_styles()` in `orchestration_controls.rs`. Both card views construct their picker handles via shared helpers in `orchestration_controls.rs` and store them in `OrchestrationPickerHandles`. The worker-host slug already flows end-to-end as a field on `OrchestrationEditState` and on `RunAgentsExecutionMode::Remote`; the missing piece is the UI control that lets a user change it. + +The workspace default slug is already exposed to the client as `defaultHostSlug` on `AmbientAgentSettings` and surfaced via `UserWorkspaces::default_host_slug()`. There is also persisted per-user "last selected host" state in `CloudAgentSettings.last_selected_host`. The Oz webapp's `HostSelector` (`client/packages/agents/src/components/HostSelector.tsx`) is the canonical reference for option ordering, default-host preselection, and recent-host surfacing. + +### Relevant files + +**New** +- `app/src/ai/blocklist/inline_action/host_picker.rs` — the `HostPicker` view itself (list mode + custom mode). +- `app/src/ai/blocklist/inline_action/host_picker_tests.rs` — unit tests for the pure helpers. + +**Shared picker plumbing (modified)** +- `app/src/ai/blocklist/inline_action/orchestration_controls.rs` — `OrchestrationPickerHandles` gains a `host_picker` handle; new helpers `populate_host_picker`, `resolve_default_host_slug`, `resolve_recent_host_slug`, and `persist_host_selection`; `sync_picker_selections` is taught to drive the host picker. +- `app/src/ai/blocklist/inline_action/mod.rs` — registers the new module. + +**Call sites (modified)** +- `app/src/ai/blocklist/inline_action/run_agents_card_view.rs` — confirmation card: builds the picker, opens its menu upward, subscribes to its events, re-dispatches `WorkerHostChanged`. +- `app/src/ai/document/orchestration_config_block.rs` — plan card: builds the picker, opts the inner menu into the overlay layer, subscribes to its events, dispatches `WorkerHostChanged`, persists field changes. + +**Reference** +- `client/packages/agents/src/components/HostSelector.tsx` (warp-server) — the webapp's host selector. + +## Proposed changes + +### 1. New `HostPicker` view + +A single non-generic view that internally switches between two render modes: + +- **List mode** wraps an inner `Dropdown` styled identically to the other orchestration pickers (`picker_styles()`). The menu is populated with the workspace default (badged "Default"), `warp`, the most-recent custom slug, and a "Custom host…" entry, in that order. Selecting any known item dispatches `InternalAction::SelectKnown(slug)`; selecting "Custom host…" dispatches `InternalAction::EnterCustomMode`. + +- **Custom mode** swaps the dropdown top bar for an inline single-line `EditorView` plus a small cancel button. Enter or blur commits via `commit_custom`; Escape or cancel reverts via `cancel_custom`. The editor is wrapped in a `Flex::column` with `MainAxisAlignment::Center` so the glyphs sit at the vertical center of the picker box (otherwise the row's tight cross-axis constraint forces the editor to fill the height and the text renders flush to the top). The custom-mode container is wrapped in an outer `Container` with vertical margins equal to `DROPDOWN_PADDING`, mirroring the standard `Dropdown` view's outer wrapping so the custom box sits at the same y offset as the other pickers in the row. + +The picker emits two public events: +- `HostPickerEvent::HostChanged { slug }` — sent whenever the current selection changes. +- `HostPickerEvent::Closed` — sent whenever the menu closes or the editor blurs, so the parent can refocus its own input. + +Public API: +- `set_options(default_host, recent_host, ctx)` — replaces the menu rows. +- `set_selected(slug, ctx)` — sets the displayed slug; unknown slugs switch into custom mode pre-filled with the slug. +- `set_use_overlay_layer(bool, ctx)` — forwarded to the inner dropdown. +- `set_menu_position(element_anchor, child_anchor, ctx)` — forwarded to the inner dropdown. + +Two subtleties worth noting in the implementation: +- The inner dropdown's `DropdownEvent::Close` is suppressed while the picker is transitioning into custom mode. If we let it through, the parent card refocuses itself, blurs the editor we just focused, and the resulting commit-on-blur immediately reverts custom mode — making "Custom host…" feel like a no-op. +- When the user types `warp` into custom mode, `commit_custom` collapses back to the standard `warp` selection rather than persisting `warp` as a custom value. This avoids the asymmetric case where `current_slug` is a casing variant of `warp` that doesn't match any menu label. + +Pure helpers (`build_menu_items`, `menu_label_for`, `normalize_slug`) live at the bottom of the module and are unit-tested without spinning up a view context. + +### 2. Shared orchestration helpers + +`OrchestrationPickerHandles` gets a new `host_picker: Option>` field. `sync_picker_selections` is taught to call `picker.set_selected(...)` with the current `worker_host` whenever the edit state changes; this handles both initial population and subsequent changes from other pickers (e.g. mode toggle resetting host to `warp`). + +Four new free functions in `orchestration_controls.rs`: + +- `populate_host_picker(picker, initial_host, ctx)` — reads the workspace default and recent slug, calls `picker.set_options(...)`, then `picker.set_selected(initial_host)`. Empty input falls back to `warp`. Used by both card views during `ensure_pickers`. + +- `resolve_default_host_slug(ctx) -> Option` — returns the workspace default slug, honoring the developer-only `WARP_CLOUD_MODE_DEFAULT_HOST` env var override, otherwise reading from `UserWorkspaces::default_host_slug()`. Mirrors the single-agent ambient flow. + +- `resolve_recent_host_slug(ctx) -> Option` — returns the persisted last-selected custom slug, deduplicated against `warp` and the workspace default (so the menu doesn't show a duplicate row). + +- `persist_host_selection(worker_host, ctx)` — writes the slug to `CloudAgentSettings.last_selected_host`. Skipped for empty values and for `warp` so those never become "recent" entries. + +Both card views also pre-fill defaults when restoring a Remote config with an empty host: prefer the workspace default over the bare `warp` fallback so self-hosted teams see their default pre-selected, matching the Oz webapp. + +### 3. Confirmation card wiring (`run_agents_card_view.rs`) + +`ensure_pickers` constructs a `HostPicker` for the new `host_picker` slot and: +- Calls `picker.set_menu_position(TopLeft, BottomLeft)` so the open menu flips upward, matching the other dropdowns in this card (which use `set_upward_menu_position` for the same reason). Without this the menu visually collides with the Environment / Base model rows below. +- Calls `populate_host_picker` to seed options and selection. +- Subscribes to `HostPickerEvent`: `HostChanged` re-dispatches the existing `RunAgentsCardViewAction::WorkerHostChanged`; `Closed` refocuses the card. + +The existing `WorkerHostChanged` handler updates `state.orch.worker_host` and calls `oc::persist_host_selection`, so any path that ends in a host change persists the slug. + +### 4. Plan-card wiring (`orchestration_config_block.rs`) + +`ensure_pickers` constructs a `HostPicker` for the new `host_picker` slot and: +- Calls `picker.set_use_overlay_layer(true)` so the menu paints above siblings, matching the other pickers in this view (which all opt into the overlay layer). +- Calls `populate_host_picker` to seed options and selection. +- Subscribes to `HostPickerEvent::HostChanged` to dispatch `OrchestrationConfigBlockAction::WorkerHostChanged`, which updates the edit state, calls `oc::persist_host_selection`, and `apply_field_change` (writes the new value into the plan's stored `OrchestrationConfig`). + +### 5. No other call sites + +The `worker_host` field already exists on `OrchestrationEditState` and on `RunAgentsExecutionMode::Remote`, so no downstream changes (dispatch, server marshalling, auto-launch matching) are needed. The previously-hardcoded `"warp"` value flows through the same code paths as any user-selected slug. + +## Testing and validation + +### Unit tests (`host_picker_tests.rs`) + +The pure helpers are tested directly without a view context. Covers product invariants 4, 5, 11, 14, 16, 18. + +- `build_menu_items` with no default and no recent → only `warp` + `Custom host…`. (Behavior 4) +- `build_menu_items` with default set → default row first, badged; then `warp`; then `Custom host…`. (Behavior 4) +- `build_menu_items` with recent set → `warp` first; then recent as plain slug; then `Custom host…`. (Behavior 4) +- `build_menu_items` dedups when recent equals default. (Behavior 5) +- `build_menu_items` dedups when recent equals `warp`. (Behavior 5) +- `build_menu_items` warp entry dispatches `SelectKnown("warp")`. (Behavior 6) +- `build_menu_items` custom entry dispatches `EnterCustomMode`. (Behavior 8) +- `menu_label_for` picks the "Default" badge when the slug matches the workspace default. (Behavior 6) +- `menu_label_for` returns plain slug for `warp`. (Behavior 6) +- `menu_label_for` returns plain slug for an unknown value (custom-mode display). (Behavior 15) +- `normalize_slug` trims whitespace and falls back to `warp` on empty input. (Behavior 14) + +### Manual validation + +The view-driven behaviors (custom-mode commit, blur, focus return, layer interaction with sibling pickers) are covered by manual smoke testing rather than view-level tests: + +- **Behavior 1, 2, 3**: Open a plan with orchestration approved and an orchestrate confirmation card; verify the host picker is present in both surfaces, only in Cloud mode, and visually matches the model / harness / environment pickers. +- **Behavior 4, 5, 6**: With and without `defaultHostSlug` configured (toggle via SQL on the local `organization_settings` table, or via `WARP_CLOUD_MODE_DEFAULT_HOST`), verify the dropdown contents and ordering, the "Default" badge, and the badge appearing in the collapsed top bar. +- **Behavior 8, 9, 10, 11, 12**: Open custom mode, verify the editor is pre-filled and focused; type a slug, press Enter; reopen the menu and verify the slug now appears as a recent entry. Repeat with Escape and with the cancel button. Try committing an empty buffer and the literal string `warp` / `WARP`. +- **Behavior 13**: Visually compare the custom-mode box to its neighbours; the editor text should be vertically centered and the box should sit at the same y as the sibling pickers. +- **Behavior 15**: Use the developer override (`WARP_CLOUD_MODE_DEFAULT_HOST=some-unknown-slug`) and verify the picker boots into custom mode pre-filled with the slug. +- **Behavior 16, 17, 18**: Pick a custom slug, dismiss the card, then reopen another plan / confirmation card; verify the slug appears as the recent entry. With a workspace default set, verify the recent entry deduplicates against it. +- **Behavior 19**: Pick a non-`warp` slug, dispatch the agents, and verify the worker-host slug reaches the worker. End-to-end smoke against a local Oz stack with a self-hosted worker registered as `local-dev` is the canonical check; worker logs show `task_claimed worker_id:"local-dev"` when the custom slug is routed correctly. +- **Behavior 20**: Open the menu in each surface and confirm it doesn't visually overlap the Environment or Base model rows. +- **Behavior 21**: After every menu close or custom-mode commit, the input box of the parent card should regain focus. + +### Presubmit + +`cargo fmt`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, and the host_picker nextest suite all pass clean. Run `./script/presubmit` before opening the PR. + +## Parallelization + +Not used. The implementation is a single new view file plus thin wiring at two call sites; the work is sequentially tightly coupled (helpers feed the view, the view feeds both call sites) and small enough that splitting across agents would add coordination overhead without saving wall-clock time.