From 0ce6d33c4f14dfad7e973d89cf3087c852d59132 Mon Sep 17 00:00:00 2001 From: Advait Maybhate Date: Fri, 15 May 2026 16:08:54 -0700 Subject: [PATCH 1/4] Add a custom host picker for orchestration Replaces the static "warp" host string in the orchestrate confirmation card and the plan-card config block with a real picker that lets users target self-hosted workers in addition to the default Warp cluster. Co-Authored-By: Oz --- .../ai/blocklist/inline_action/host_picker.rs | 553 ++++++++++++++++++ .../inline_action/host_picker_tests.rs | 123 ++++ app/src/ai/blocklist/inline_action/mod.rs | 1 + .../inline_action/orchestration_controls.rs | 95 ++- .../inline_action/run_agents_card_view.rs | 40 +- .../ai/document/orchestration_config_block.rs | 30 +- 6 files changed, 798 insertions(+), 44 deletions(-) create mode 100644 app/src/ai/blocklist/inline_action/host_picker.rs create mode 100644 app/src/ai/blocklist/inline_action/host_picker_tests.rs 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..d78cc976ca --- /dev/null +++ b/app/src/ai/blocklist/inline_action/host_picker.rs @@ -0,0 +1,553 @@ +//! Custom host picker used in the orchestration UI. +//! +//! Two display modes: +//! - **List mode (default):** behaves like a standard orchestration picker — +//! a styled top bar opens a `Menu` of known options (workspace default +//! first when set and badged "Default", then `warp`, then the most- +//! recent custom host as a plain slug, plus a `Custom host…` entry). +//! - **Custom mode:** swaps the top bar for an inline [`EditorView`] so the +//! user can type an arbitrary self-hosted worker slug. Enter or blur +//! commits the trimmed value; the small `×` button or Escape reverts to +//! list mode. +//! +//! Layout mirrors the Oz webapp's `HostSelector`: workspace default sits +//! at the top with a "Default" badge so admin-configured teams get their +//! preferred host pre-selected. Recent custom hosts render as plain +//! slugs (no "Recent" badge) because the compact dropdown doesn't have +//! room for the extra chip. +//! +//! The picker is non-generic and reports value changes via +//! [`HostPickerEvent::HostChanged`]. Card views subscribe and re-dispatch +//! their own `WorkerHostChanged` action so the existing edit-state plumbing +//! continues to work unchanged. + +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 ──────────────────────────────────────────────── + +/// Public events emitted by [`HostPicker`]. +#[derive(Debug, Clone)] +pub enum HostPickerEvent { + /// User selected a value from the menu or committed a custom entry. + /// `slug` is non-empty and trimmed. + HostChanged { slug: String }, + /// The menu closed (selection made, dismissed) or the custom-mode + /// editor blurred. Parent views use this to refocus their input. + Closed, +} + +const CUSTOM_HOST_LABEL: &str = "Custom host…"; +const DEFAULT_BADGE: &str = "Default"; +const EDITOR_PLACEHOLDER: &str = "my-worker-host"; + +// ── Internal action plumbing ──────────────────────────────────────── + +/// Action dispatched by the inner `Dropdown` items and +/// the inline `×` button. Handled by [`HostPicker`] itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InternalAction { + /// Pick a known host (Warp, workspace default, recent slug). + SelectKnown(String), + /// Switch to custom-mode text input. + EnterCustomMode, + /// Exit custom mode without committing the editor contents. + CancelCustom, +} + +// ── View ──────────────────────────────────────────────────────────── + +pub struct HostPicker { + /// Currently displayed slug. Always equal to what would be sent to + /// the server if the picker were dispatched right now. + current_slug: String, + /// Workspace-configured default, when set. Shown badged as "Default" + /// and surfaced as the top row, matching the Oz webapp. + default_host: Option, + /// User's most-recent custom host (excluding warp / default). + recent_host: Option, + /// Inner menu-based dropdown rendered in list mode. + dropdown: ViewHandle>, + /// Inline editor used in custom mode. + editor: ViewHandle, + /// Mouse state for the small `×` clear button. + clear_mouse_state: MouseStateHandle, + /// Whether we're currently showing the inline editor. + is_custom_mode: bool, + /// Snapshot of `current_slug` taken when the editor was opened, so + /// Escape / `×` can revert without committing. + 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 identically to the other orchestration + // pickers so the row stays visually uniform. + 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 { + // Suppress the propagated Closed event when we're + // transitioning into custom mode. Otherwise the parent's + // `refocus_after_picker_close` would steal focus from the + // editor we just focused, the editor would fire `Blurred`, + // and `commit_custom` would immediately revert us out of + // custom mode — making the "Custom host…" menu item + // look like a no-op to the user. + if me.is_custom_mode { + return; + } + ctx.emit(HostPickerEvent::Closed); + ctx.notify(); + } + }); + + // Inline editor for custom mode. + 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 workspace default and recent-host options shown in + /// the menu. Pass `None` to omit a given row. + 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(); + } + + /// Forwards to the inner dropdown's [`Dropdown::set_use_overlay_layer`]. + /// Callers in the plan card pass `true` so the open menu paints in the + /// overlay layer above sibling pickers (matching the other orchestration + /// pickers in that view); callers in the confirmation card leave it at + /// the default `false` and instead flip the menu upward via + /// [`Self::set_menu_position`]. + 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); + }); + } + + /// Forwards to the inner dropdown's [`Dropdown::set_menu_position`]. + /// The confirmation card uses this to open the menu upward, avoiding + /// visual overlap with the Environment / Base model pickers rendered + /// directly below the host picker. + 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 currently-displayed slug. If the slug doesn't match any + /// known menu option (Warp, default, recent), the picker switches to + /// 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 = { + let trimmed = slug.trim(); + if trimmed.is_empty() { + ORCHESTRATION_WARP_WORKER_HOST.to_string() + } else { + trimmed.to_string() + } + }; + 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.recent_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 whatever is in the editor right now. Empty input is + /// treated as a no-op revert (stays at the previous slug). + fn commit_custom(&mut self, ctx: &mut ViewContext) { + let raw = self.editor.as_ref(ctx).buffer_text(ctx).trim().to_string(); + if raw.is_empty() { + self.cancel_custom(ctx); + return; + } + self.current_slug = raw.clone(); + self.is_custom_mode = false; + self.slug_before_edit = None; + // If the committed slug isn't already one of the known options, + // surface it as the new "Recent" entry so it's available in the + // list on the next paint. The parent will also persist it + // out-of-band so it survives across cards. + 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(); + + // Wrap the editor in a column with `MainAxisAlignment::Center` so its + // text baseline sits at the vertical center of the picker box. Without + // this, the surrounding row's tight cross-axis constraint stretches the + // editor to fill the full 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(); + + // The standard `Dropdown` view (used by every other picker in the + // row) wraps its top bar in a Container with `DROPDOWN_PADDING` + // top/bottom margins. Mirror that here so custom mode sits at the + // same y offset as the other pickers instead of riding ~6px 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) ───────────────────── + +/// Builds the menu items shown in list mode. Items appear in this order: +/// workspace default (if set, badged "Default"), `warp`, recent custom +/// host (if set and not a duplicate, plain slug), then "Custom host…". +/// Mirrors the Oz webapp's `HostSelector` layout. +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 custom hosts render as plain slugs — no "(Recent)" + // suffix. The "Default" badge stays because it carries a + // distinct admin-policy meaning. + 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 that corresponds to `slug` (so callers can +/// re-select it via `set_selected_by_name`). The label includes the +/// "Default" badge when the slug matches the workspace default; recent +/// custom hosts render as plain slugs. +pub(crate) fn menu_label_for( + slug: &str, + default_host: Option<&str>, + _recent_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; + // Intentionally NOT calling sync_dropdown_selection: this + // action was dispatched FROM the inner dropdown, which + // already updated its own `selected_item` via + // `MenuEvent::ItemSelected`. Re-entering its view from + // here would panic with `Circular view 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..4ff97d138f --- /dev/null +++ b/app/src/ai/blocklist/inline_action/host_picker_tests.rs @@ -0,0 +1,123 @@ +//! Unit tests for the pure helpers in `host_picker.rs`. These exercise +//! menu-item composition and label formatting without spinning up a view +//! context — those code paths are covered by manual smoke testing. + +use super::{ + build_menu_items, menu_label_for, 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"), None); + assert_eq!(label, "my-corp (Default)"); +} + +#[test] +fn menu_label_for_returns_plain_slug_when_slug_matches_recent_only() { + // Recent hosts render as plain slugs — only the workspace default + // gets a badge. + let label = menu_label_for("other-host", Some("my-corp"), Some("other-host")); + assert_eq!(label, "other-host"); +} + +#[test] +fn menu_label_for_returns_plain_slug_for_warp() { + let label = menu_label_for( + ORCHESTRATION_WARP_WORKER_HOST, + Some("my-corp"), + Some(ORCHESTRATION_WARP_WORKER_HOST), + ); + 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"), None); + assert_eq!(label, "typed-once"); +} 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..2c2862d473 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,29 @@ 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); + // Flip the menu upward so it doesn't visually overlap the + // Environment / Base model pickers rendered directly below the + // host picker. Matches the other dropdowns in this card, which + // use `set_upward_menu_position` for the same reason. + 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 +941,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..9a4a32e578 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,21 @@ 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); + // Match the overlay-layer behavior of the other pickers in this view + // so the open menu paints above siblings instead of being visually + // overlapped by the Environment / Base model pickers below it. + 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 +623,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(); } From f54319238e4c74e8cc6a6e7bcd7c5b3475cfe4f0 Mon Sep 17 00:00:00 2001 From: Advait Maybhate Date: Fri, 15 May 2026 16:23:56 -0700 Subject: [PATCH 2/4] Tighten host picker comments and normalize slug handling Co-Authored-By: Oz --- .../ai/blocklist/inline_action/host_picker.rs | 187 +++++++----------- .../inline_action/host_picker_tests.rs | 33 ++-- .../inline_action/run_agents_card_view.rs | 6 +- .../ai/document/orchestration_config_block.rs | 5 +- 4 files changed, 89 insertions(+), 142 deletions(-) diff --git a/app/src/ai/blocklist/inline_action/host_picker.rs b/app/src/ai/blocklist/inline_action/host_picker.rs index d78cc976ca..1df40199fe 100644 --- a/app/src/ai/blocklist/inline_action/host_picker.rs +++ b/app/src/ai/blocklist/inline_action/host_picker.rs @@ -1,25 +1,10 @@ -//! Custom host picker used in the orchestration UI. +//! Picker for the cloud-agent worker host slug. //! -//! Two display modes: -//! - **List mode (default):** behaves like a standard orchestration picker — -//! a styled top bar opens a `Menu` of known options (workspace default -//! first when set and badged "Default", then `warp`, then the most- -//! recent custom host as a plain slug, plus a `Custom host…` entry). -//! - **Custom mode:** swaps the top bar for an inline [`EditorView`] so the -//! user can type an arbitrary self-hosted worker slug. Enter or blur -//! commits the trimmed value; the small `×` button or Escape reverts to -//! list mode. -//! -//! Layout mirrors the Oz webapp's `HostSelector`: workspace default sits -//! at the top with a "Default" badge so admin-configured teams get their -//! preferred host pre-selected. Recent custom hosts render as plain -//! slugs (no "Recent" badge) because the compact dropdown doesn't have -//! room for the extra chip. -//! -//! The picker is non-generic and reports value changes via -//! [`HostPickerEvent::HostChanged`]. Card views subscribe and re-dispatch -//! their own `WorkerHostChanged` action so the existing edit-state plumbing -//! continues to work unchanged. +//! 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, @@ -51,14 +36,13 @@ use crate::view_components::dropdown::{ // ── Public API types ──────────────────────────────────────────────── -/// Public events emitted by [`HostPicker`]. #[derive(Debug, Clone)] pub enum HostPickerEvent { - /// User selected a value from the menu or committed a custom entry. - /// `slug` is non-empty and trimmed. + /// Emitted with a non-empty, trimmed slug whenever the user picks a + /// known host or commits a custom entry. HostChanged { slug: String }, - /// The menu closed (selection made, dismissed) or the custom-mode - /// editor blurred. Parent views use this to refocus their input. + /// Emitted when the menu closes or the inline editor blurs, so the + /// parent can refocus its own input. Closed, } @@ -68,11 +52,10 @@ const EDITOR_PLACEHOLDER: &str = "my-worker-host"; // ── Internal action plumbing ──────────────────────────────────────── -/// Action dispatched by the inner `Dropdown` items and -/// the inline `×` button. Handled by [`HostPicker`] itself. +/// 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, recent slug). + /// Pick a known host (warp, workspace default, or a recent slug). SelectKnown(String), /// Switch to custom-mode text input. EnterCustomMode, @@ -83,24 +66,17 @@ pub enum InternalAction { // ── View ──────────────────────────────────────────────────────────── pub struct HostPicker { - /// Currently displayed slug. Always equal to what would be sent to - /// the server if the picker were dispatched right now. + /// The slug that would be sent to the server if dispatched now. current_slug: String, - /// Workspace-configured default, when set. Shown badged as "Default" - /// and surfaced as the top row, matching the Oz webapp. + /// Admin-configured workspace default, when set. default_host: Option, - /// User's most-recent custom host (excluding warp / default). + /// User's most-recent custom host, deduped against warp / default. recent_host: Option, - /// Inner menu-based dropdown rendered in list mode. dropdown: ViewHandle>, - /// Inline editor used in custom mode. editor: ViewHandle, - /// Mouse state for the small `×` clear button. clear_mouse_state: MouseStateHandle, - /// Whether we're currently showing the inline editor. is_custom_mode: bool, - /// Snapshot of `current_slug` taken when the editor was opened, so - /// Escape / `×` can revert without committing. + /// Snapshot taken when the editor was opened so cancel can revert. slug_before_edit: Option, } @@ -108,8 +84,7 @@ impl HostPicker { pub fn new(ctx: &mut ViewContext) -> Self { let (_styles, colors) = oc::picker_styles(Appearance::as_ref(ctx)); - // Inner dropdown — styled identically to the other orchestration - // pickers so the row stays visually uniform. + // 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); @@ -126,13 +101,10 @@ impl HostPicker { }); ctx.subscribe_to_view(&dropdown, |me, _, event, ctx| { if let DropdownEvent::Close = event { - // Suppress the propagated Closed event when we're - // transitioning into custom mode. Otherwise the parent's - // `refocus_after_picker_close` would steal focus from the - // editor we just focused, the editor would fire `Blurred`, - // and `commit_custom` would immediately revert us out of - // custom mode — making the "Custom host…" menu item - // look like a no-op to the user. + // 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; } @@ -141,7 +113,6 @@ impl HostPicker { } }); - // Inline editor for custom mode. let editor = ctx.add_typed_action_view(|ctx_editor| { let appearance = Appearance::as_ref(ctx_editor); let mut editor = EditorView::single_line( @@ -178,8 +149,7 @@ impl HostPicker { // ── Public API ────────────────────────────────────────────────── - /// Replaces the workspace default and recent-host options shown in - /// the menu. Pass `None` to omit a given row. + /// Replaces the default and recent menu rows. Pass `None` to omit one. pub fn set_options( &mut self, default_host: Option, @@ -193,22 +163,15 @@ impl HostPicker { ctx.notify(); } - /// Forwards to the inner dropdown's [`Dropdown::set_use_overlay_layer`]. - /// Callers in the plan card pass `true` so the open menu paints in the - /// overlay layer above sibling pickers (matching the other orchestration - /// pickers in that view); callers in the confirmation card leave it at - /// the default `false` and instead flip the menu upward via - /// [`Self::set_menu_position`]. + /// 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); }); } - /// Forwards to the inner dropdown's [`Dropdown::set_menu_position`]. - /// The confirmation card uses this to open the menu upward, avoiding - /// visual overlap with the Environment / Base model pickers rendered - /// directly below the host picker. + /// Anchors the open menu (e.g. flip upward to avoid covering siblings). pub fn set_menu_position( &mut self, element_anchor: PositionedElementAnchor, @@ -220,19 +183,10 @@ impl HostPicker { }); } - /// Sets the currently-displayed slug. If the slug doesn't match any - /// known menu option (Warp, default, recent), the picker switches to - /// custom mode pre-filled with the slug. Empty input falls back to - /// `"warp"`. + /// 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 = { - let trimmed = slug.trim(); - if trimmed.is_empty() { - ORCHESTRATION_WARP_WORKER_HOST.to_string() - } else { - trimmed.to_string() - } - }; + let effective = normalize_slug(slug); let is_known = self.is_known_option(&effective); self.current_slug = effective.clone(); if is_known { @@ -267,11 +221,7 @@ impl HostPicker { } fn sync_dropdown_selection(&mut self, ctx: &mut ViewContext) { - let label = menu_label_for( - &self.current_slug, - self.default_host.as_deref(), - self.recent_host.as_deref(), - ); + 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); }); @@ -291,10 +241,22 @@ impl HostPicker { ctx.focus(&self.editor); } - /// Commits whatever is in the editor right now. Empty input is - /// treated as a no-op revert (stays at the previous slug). + /// Commits the editor contents. Empty input reverts to the previous slug. fn commit_custom(&mut self, ctx: &mut ViewContext) { - let raw = self.editor.as_ref(ctx).buffer_text(ctx).trim().to_string(); + let raw = normalize_slug(&self.editor.as_ref(ctx).buffer_text(ctx)); + 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; + } if raw.is_empty() { self.cancel_custom(ctx); return; @@ -302,10 +264,8 @@ impl HostPicker { self.current_slug = raw.clone(); self.is_custom_mode = false; self.slug_before_edit = None; - // If the committed slug isn't already one of the known options, - // surface it as the new "Recent" entry so it's available in the - // list on the next paint. The parent will also persist it - // out-of-band so it survives across cards. + // 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); @@ -347,11 +307,9 @@ impl HostPicker { let background: Fill = theme.surface_overlay_1(); let border_color = theme.outline(); - // Wrap the editor in a column with `MainAxisAlignment::Center` so its - // text baseline sits at the vertical center of the picker box. Without - // this, the surrounding row's tight cross-axis constraint stretches the - // editor to fill the full content height and the glyphs render flush - // to the top. + // 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) @@ -368,10 +326,8 @@ impl HostPicker { .with_child(cancel_button) .finish(); - // The standard `Dropdown` view (used by every other picker in the - // row) wraps its top bar in a Container with `DROPDOWN_PADDING` - // top/bottom margins. Mirror that here so custom mode sits at the - // same y offset as the other pickers instead of riding ~6px higher. + // 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) @@ -419,10 +375,19 @@ impl HostPicker { // ── Pure helpers (also exercised by unit tests) ───────────────────── -/// Builds the menu items shown in list mode. Items appear in this order: -/// workspace default (if set, badged "Default"), `warp`, recent custom -/// host (if set and not a duplicate, plain slug), then "Custom host…". -/// Mirrors the Oz webapp's `HostSelector` layout. +/// 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>, @@ -444,9 +409,8 @@ pub(crate) fn build_menu_items( if let Some(slug) = recent_host { if default_host != Some(slug) && !slug.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { - // Recent custom hosts render as plain slugs — no "(Recent)" - // suffix. The "Default" badge stays because it carries a - // distinct admin-policy meaning. + // Recent hosts render as plain slugs; only the workspace + // default carries a badge. items.push(menu_item_for_known( slug, None, @@ -463,15 +427,9 @@ pub(crate) fn build_menu_items( items } -/// Returns the menu label that corresponds to `slug` (so callers can -/// re-select it via `set_selected_by_name`). The label includes the -/// "Default" badge when the slug matches the workspace default; recent -/// custom hosts render as plain slugs. -pub(crate) fn menu_label_for( - slug: &str, - default_host: Option<&str>, - _recent_host: Option<&str>, -) -> String { +/// 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 { @@ -513,11 +471,8 @@ impl TypedActionView for HostPicker { self.current_slug = slug.clone(); self.is_custom_mode = false; self.slug_before_edit = None; - // Intentionally NOT calling sync_dropdown_selection: this - // action was dispatched FROM the inner dropdown, which - // already updated its own `selected_item` via - // `MenuEvent::ItemSelected`. Re-entering its view from - // here would panic with `Circular view update`. + // The inner dropdown already updated its own selection; + // re-entering it here would trigger a circular update. ctx.emit(HostPickerEvent::HostChanged { slug }); ctx.notify(); } diff --git a/app/src/ai/blocklist/inline_action/host_picker_tests.rs b/app/src/ai/blocklist/inline_action/host_picker_tests.rs index 4ff97d138f..2347419770 100644 --- a/app/src/ai/blocklist/inline_action/host_picker_tests.rs +++ b/app/src/ai/blocklist/inline_action/host_picker_tests.rs @@ -1,9 +1,9 @@ -//! Unit tests for the pure helpers in `host_picker.rs`. These exercise -//! menu-item composition and label formatting without spinning up a view -//! context — those code paths are covered by manual smoke testing. +//! 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, DropdownAction, InternalAction, MenuItem, + build_menu_items, menu_label_for, normalize_slug, DropdownAction, InternalAction, MenuItem, ORCHESTRATION_WARP_WORKER_HOST, }; @@ -93,31 +93,26 @@ fn build_menu_items_custom_entry_dispatches_enter_custom_mode() { #[test] fn menu_label_for_picks_default_badge_when_slug_matches_default() { - let label = menu_label_for("my-corp", Some("my-corp"), None); + let label = menu_label_for("my-corp", Some("my-corp")); assert_eq!(label, "my-corp (Default)"); } -#[test] -fn menu_label_for_returns_plain_slug_when_slug_matches_recent_only() { - // Recent hosts render as plain slugs — only the workspace default - // gets a badge. - let label = menu_label_for("other-host", Some("my-corp"), Some("other-host")); - assert_eq!(label, "other-host"); -} - #[test] fn menu_label_for_returns_plain_slug_for_warp() { - let label = menu_label_for( - ORCHESTRATION_WARP_WORKER_HOST, - Some("my-corp"), - Some(ORCHESTRATION_WARP_WORKER_HOST), - ); + 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"), None); + 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/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 2c2862d473..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 @@ -671,10 +671,8 @@ impl RunAgentsCardView { RunAgentsExecutionMode::Local => oc::ORCHESTRATION_WARP_WORKER_HOST, }; let handle = ctx.add_typed_action_view(HostPicker::new); - // Flip the menu upward so it doesn't visually overlap the - // Environment / Base model pickers rendered directly below the - // host picker. Matches the other dropdowns in this card, which - // use `set_upward_menu_position` for the same reason. + // 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, diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index 9a4a32e578..6657e13e25 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -333,9 +333,8 @@ impl OrchestrationConfigBlockView { RunAgentsExecutionMode::Local => oc::ORCHESTRATION_WARP_WORKER_HOST, }; let host_handle = ctx.add_typed_action_view(HostPicker::new); - // Match the overlay-layer behavior of the other pickers in this view - // so the open menu paints above siblings instead of being visually - // overlapped by the Environment / Base model pickers below it. + // 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); }); From abf288ec7d24b501ac087102e2b5c684b27b9d6d Mon Sep 17 00:00:00 2001 From: advait-m Date: Fri, 15 May 2026 16:29:16 -0700 Subject: [PATCH 3/4] add specs --- specs/QUALITY-701/PRODUCT.md | 70 +++++++++++++++++++ specs/QUALITY-701/TECH.md | 132 +++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 specs/QUALITY-701/PRODUCT.md create mode 100644 specs/QUALITY-701/TECH.md 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. From 83abf000890dcb1c5cd7c8e75254f4003f14b91e Mon Sep 17 00:00:00 2001 From: advait-m Date: Fri, 15 May 2026 17:51:34 -0700 Subject: [PATCH 4/4] fix empty input case --- app/src/ai/blocklist/inline_action/host_picker.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/ai/blocklist/inline_action/host_picker.rs b/app/src/ai/blocklist/inline_action/host_picker.rs index 1df40199fe..7919bf6592 100644 --- a/app/src/ai/blocklist/inline_action/host_picker.rs +++ b/app/src/ai/blocklist/inline_action/host_picker.rs @@ -243,7 +243,14 @@ impl HostPicker { /// Commits the editor contents. Empty input reverts to the previous slug. fn commit_custom(&mut self, ctx: &mut ViewContext) { - let raw = normalize_slug(&self.editor.as_ref(ctx).buffer_text(ctx)); + // 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(); @@ -257,10 +264,6 @@ impl HostPicker { ctx.notify(); return; } - if raw.is_empty() { - self.cancel_custom(ctx); - return; - } self.current_slug = raw.clone(); self.is_custom_mode = false; self.slug_before_edit = None;