Skip to content

Commit 33f3284

Browse files
authored
Add a custom host picker for orchestration (#11080)
1 parent 93ef0ac commit 33f3284

8 files changed

Lines changed: 950 additions & 44 deletions

File tree

app/src/ai/blocklist/inline_action/host_picker.rs

Lines changed: 511 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//! Unit tests for the pure helpers in `host_picker.rs`. View-driven
2+
//! behaviors (custom-mode commit, blur, etc.) are covered by manual smoke
3+
//! testing.
4+
5+
use super::{
6+
build_menu_items, menu_label_for, normalize_slug, DropdownAction, InternalAction, MenuItem,
7+
ORCHESTRATION_WARP_WORKER_HOST,
8+
};
9+
10+
/// Extracts the visible label text out of a `MenuItem::Item`, panicking
11+
/// on the unreachable `Header` / `Separator` cases that our builder
12+
/// doesn't emit.
13+
fn item_label(item: &MenuItem<DropdownAction<InternalAction>>) -> &str {
14+
match item {
15+
MenuItem::Item(fields) => fields.label(),
16+
other => panic!("expected MenuItem::Item, got {other:?}"),
17+
}
18+
}
19+
20+
/// Extracts the on-select action from a `MenuItem::Item`.
21+
fn item_action(item: &MenuItem<DropdownAction<InternalAction>>) -> &DropdownAction<InternalAction> {
22+
match item {
23+
MenuItem::Item(fields) => fields
24+
.on_select_action()
25+
.expect("test items always have a select action"),
26+
other => panic!("expected MenuItem::Item, got {other:?}"),
27+
}
28+
}
29+
30+
#[test]
31+
fn build_menu_items_with_no_defaults_shows_warp_and_custom() {
32+
let items = build_menu_items(None, None);
33+
assert_eq!(items.len(), 2, "expected warp + custom-host entries");
34+
assert_eq!(item_label(&items[0]), ORCHESTRATION_WARP_WORKER_HOST);
35+
assert_eq!(item_label(&items[1]), "Custom host\u{2026}");
36+
}
37+
38+
#[test]
39+
fn build_menu_items_promotes_default_to_top() {
40+
// Workspace default sits above warp and gets the "Default" badge,
41+
// matching the Oz webapp's HostSelector layout.
42+
let items = build_menu_items(Some("my-corp"), None);
43+
assert_eq!(items.len(), 3);
44+
assert_eq!(item_label(&items[0]), "my-corp (Default)");
45+
assert_eq!(item_label(&items[1]), ORCHESTRATION_WARP_WORKER_HOST);
46+
assert_eq!(item_label(&items[2]), "Custom host\u{2026}");
47+
}
48+
49+
#[test]
50+
fn build_menu_items_adds_recent_after_warp() {
51+
let items = build_menu_items(None, Some("other-host"));
52+
assert_eq!(items.len(), 3);
53+
assert_eq!(item_label(&items[0]), ORCHESTRATION_WARP_WORKER_HOST);
54+
// Recent hosts render as plain slugs (no "(Recent)" suffix).
55+
assert_eq!(item_label(&items[1]), "other-host");
56+
assert_eq!(item_label(&items[2]), "Custom host\u{2026}");
57+
}
58+
59+
#[test]
60+
fn build_menu_items_dedups_recent_when_it_matches_default_or_warp() {
61+
// Same as the workspace default → no duplicate "Recent" row.
62+
let items = build_menu_items(Some("my-corp"), Some("my-corp"));
63+
assert_eq!(items.len(), 3);
64+
assert_eq!(item_label(&items[0]), "my-corp (Default)");
65+
assert_eq!(item_label(&items[1]), ORCHESTRATION_WARP_WORKER_HOST);
66+
assert_eq!(item_label(&items[2]), "Custom host\u{2026}");
67+
68+
// Recent == "warp" is also skipped (warp is already a row).
69+
let items = build_menu_items(Some("my-corp"), Some("warp"));
70+
assert_eq!(items.len(), 3, "warp recent should not double-add");
71+
}
72+
73+
#[test]
74+
fn build_menu_items_warp_entry_dispatches_select_known_warp() {
75+
let items = build_menu_items(None, None);
76+
match item_action(&items[0]) {
77+
DropdownAction::SelectActionAndClose(InternalAction::SelectKnown(slug)) => {
78+
assert_eq!(slug, ORCHESTRATION_WARP_WORKER_HOST);
79+
}
80+
other => panic!("expected SelectActionAndClose(SelectKnown), got {other:?}"),
81+
}
82+
}
83+
84+
#[test]
85+
fn build_menu_items_custom_entry_dispatches_enter_custom_mode() {
86+
let items = build_menu_items(None, None);
87+
let custom = items.last().expect("custom entry is always last");
88+
match item_action(custom) {
89+
DropdownAction::SelectActionAndClose(InternalAction::EnterCustomMode) => {}
90+
other => panic!("expected EnterCustomMode, got {other:?}"),
91+
}
92+
}
93+
94+
#[test]
95+
fn menu_label_for_picks_default_badge_when_slug_matches_default() {
96+
let label = menu_label_for("my-corp", Some("my-corp"));
97+
assert_eq!(label, "my-corp (Default)");
98+
}
99+
100+
#[test]
101+
fn menu_label_for_returns_plain_slug_for_warp() {
102+
let label = menu_label_for(ORCHESTRATION_WARP_WORKER_HOST, Some("my-corp"));
103+
assert_eq!(label, ORCHESTRATION_WARP_WORKER_HOST);
104+
}
105+
106+
#[test]
107+
fn menu_label_for_returns_plain_slug_for_unknown_value() {
108+
// A slug typed via custom mode that we haven't promoted to "recent" yet.
109+
let label = menu_label_for("typed-once", Some("my-corp"));
110+
assert_eq!(label, "typed-once");
111+
}
112+
113+
#[test]
114+
fn normalize_slug_trims_whitespace_and_falls_back_to_warp_when_empty() {
115+
assert_eq!(normalize_slug(" my-corp "), "my-corp");
116+
assert_eq!(normalize_slug(""), ORCHESTRATION_WARP_WORKER_HOST);
117+
assert_eq!(normalize_slug(" "), ORCHESTRATION_WARP_WORKER_HOST);
118+
}

app/src/ai/blocklist/inline_action/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub(crate) mod ask_user_question_view;
22
pub(super) mod aws_bedrock_credentials_error;
33
pub(crate) mod code_diff_view;
44
pub(crate) mod create_or_edit_document;
5+
pub(crate) mod host_picker;
56
pub(crate) mod inline_action_header;
67
pub(crate) mod inline_action_icons;
78
mod malformed_line_heuristics;

app/src/ai/blocklist/inline_action/orchestration_controls.rs

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ use warpui::{
2828

2929
use settings::Setting;
3030
use warp_cli::agent::Harness;
31-
use warp_core::channel::{Channel, ChannelState};
3231
use warp_core::features::FeatureFlag;
3332
use warp_core::ui::theme::Fill;
3433

3534
use crate::ai::auth_secret_types::auth_secret_types_for_harness;
35+
use crate::ai::blocklist::inline_action::host_picker::HostPicker;
3636
use crate::ai::cloud_agent_settings::CloudAgentSettings;
3737
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
3838
use crate::ai::execution_profiles::model_menu_items::available_model_menu_items;
@@ -47,8 +47,13 @@ use crate::report_if_error;
4747
use crate::ui_components::blended_colors;
4848
use crate::view_components::dropdown::{Dropdown, DropdownAction, DropdownStyle};
4949
use crate::view_components::FilterableDropdown;
50+
use crate::workspaces::user_workspaces::UserWorkspaces;
5051
use crate::LLMPreferences;
5152

53+
/// Env var override for the workspace default host (developer testing).
54+
/// Mirrors the single-agent ambient flow.
55+
const DEFAULT_HOST_ENV_VAR: &str = "WARP_CLOUD_MODE_DEFAULT_HOST";
56+
5257
// ── Shared constants ────────────────────────────────────────────────
5358

5459
pub const ORCHESTRATION_WARP_WORKER_HOST: &str = "warp";
@@ -78,7 +83,6 @@ pub trait OrchestrationControlAction: Clone + Debug + Send + Sync + 'static {
7883
fn model_changed(model_id: String) -> Self;
7984
fn harness_changed(harness_type: String) -> Self;
8085
fn environment_changed(environment_id: String) -> Self;
81-
fn worker_host_changed(worker_host: String) -> Self;
8286
/// Fires when the auth secret picker selects a managed secret.
8387
/// `None` means "clear the selection / inherit from environment".
8488
fn auth_secret_changed(name: Option<String>) -> Self;
@@ -291,7 +295,7 @@ pub struct OrchestrationPickerHandles<A: OrchestrationControlAction> {
291295
pub model_picker: Option<ViewHandle<Dropdown<A>>>,
292296
pub harness_picker: Option<ViewHandle<Dropdown<A>>>,
293297
pub environment_picker: Option<ViewHandle<FilterableDropdown<A>>>,
294-
pub host_picker: Option<ViewHandle<Dropdown<A>>>,
298+
pub host_picker: Option<ViewHandle<HostPicker>>,
295299
/// Picker for the managed auth secret used by non-Oz cloud children.
296300
/// `None` when the picker hasn't been built yet (e.g. harness is Oz or
297301
/// execution mode is Local), or when the harness has no supported
@@ -688,37 +692,72 @@ pub fn create_environment_picker<A: OrchestrationControlAction, V: View>(
688692
dropdown_handle
689693
}
690694

691-
pub fn populate_host_picker<A: OrchestrationControlAction, V: View>(
692-
dropdown: &ViewHandle<Dropdown<A>>,
695+
/// Repopulates the host picker with the workspace default (if any) and
696+
/// the user's last-selected custom host (if any), then sets the current
697+
/// selection to `initial_host`.
698+
pub fn populate_host_picker<V: View>(
699+
picker: &ViewHandle<HostPicker>,
693700
initial_host: &str,
694701
ctx: &mut ViewContext<V>,
695702
) {
696-
let initial_host = if initial_host.is_empty() {
703+
let default_host = resolve_default_host_slug(ctx);
704+
let recent_host = resolve_recent_host_slug(ctx);
705+
let initial = if initial_host.trim().is_empty() {
697706
ORCHESTRATION_WARP_WORKER_HOST.to_string()
698707
} else {
699708
initial_host.to_string()
700709
};
701-
dropdown.update(ctx, |dropdown, ctx_dropdown| {
702-
let hosts: &[&str] = if matches!(ChannelState::channel(), Channel::Local) {
703-
&["warp", "local-dev"]
704-
} else {
705-
&["warp"]
706-
};
707-
let mut items: Vec<MenuItem<DropdownAction<A>>> = Vec::new();
708-
let mut selected_idx = None;
709-
for (idx, &host) in hosts.iter().enumerate() {
710-
let fields = MenuItemFields::new(host).with_on_select_action(
711-
DropdownAction::SelectActionAndClose(A::worker_host_changed(host.to_string())),
712-
);
713-
if host.eq_ignore_ascii_case(&initial_host) {
714-
selected_idx = Some(idx);
715-
}
716-
items.push(MenuItem::Item(fields));
717-
}
718-
dropdown.set_rich_items(items, ctx_dropdown);
719-
if let Some(idx) = selected_idx {
720-
dropdown.set_selected_by_index(idx, ctx_dropdown);
710+
picker.update(ctx, |picker, picker_ctx| {
711+
picker.set_options(default_host, recent_host, picker_ctx);
712+
picker.set_selected(&initial, picker_ctx);
713+
});
714+
}
715+
716+
/// Resolves the workspace-configured default host slug, honoring the
717+
/// `WARP_CLOUD_MODE_DEFAULT_HOST` env var override for developer
718+
/// testing. Mirrors the single-agent ambient flow.
719+
pub fn resolve_default_host_slug(ctx: &AppContext) -> Option<String> {
720+
if let Ok(slug) = std::env::var(DEFAULT_HOST_ENV_VAR) {
721+
let trimmed = slug.trim();
722+
if !trimmed.is_empty() {
723+
return Some(trimmed.to_string());
721724
}
725+
}
726+
UserWorkspaces::as_ref(ctx)
727+
.default_host_slug()
728+
.map(str::to_string)
729+
.filter(|s| !s.trim().is_empty())
730+
}
731+
732+
/// Returns the user's last-selected custom host slug from
733+
/// `CloudAgentSettings.last_selected_host`, excluding `"warp"` and the
734+
/// workspace default (those are surfaced as separate menu rows).
735+
pub fn resolve_recent_host_slug(ctx: &AppContext) -> Option<String> {
736+
let last = CloudAgentSettings::as_ref(ctx)
737+
.last_selected_host
738+
.value()
739+
.clone()
740+
.filter(|s| !s.trim().is_empty())?;
741+
if last.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) {
742+
return None;
743+
}
744+
if resolve_default_host_slug(ctx).as_deref() == Some(last.as_str()) {
745+
return None;
746+
}
747+
Some(last)
748+
}
749+
750+
/// Persists the user's most-recent host selection to
751+
/// `CloudAgentSettings.last_selected_host`. Skipped for `"warp"` and
752+
/// empty values (those don't represent a custom slug worth remembering).
753+
pub fn persist_host_selection<V: View>(worker_host: &str, ctx: &mut ViewContext<V>) {
754+
let trimmed = worker_host.trim();
755+
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) {
756+
return;
757+
}
758+
let value = trimmed.to_string();
759+
CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| {
760+
report_if_error!(settings.last_selected_host.set_value(Some(value), ctx));
722761
});
723762
}
724763

@@ -1188,8 +1227,8 @@ pub fn sync_picker_selections<A: OrchestrationControlAction, V: View>(
11881227
RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.clone(),
11891228
RunAgentsExecutionMode::Local => ORCHESTRATION_WARP_WORKER_HOST.to_string(),
11901229
};
1191-
host_picker.update(ctx, |dropdown, ctx_dropdown| {
1192-
dropdown.set_selected_by_name(&worker_host, ctx_dropdown);
1230+
host_picker.update(ctx, |picker, picker_ctx| {
1231+
picker.set_selected(&worker_host, picker_ctx);
11931232
});
11941233
}
11951234
if let Some(auth_secret_picker) = handles.auth_secret_picker.clone() {

app/src/ai/blocklist/inline_action/run_agents_card_view.rs

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use crate::ai::blocklist::agent_view::orchestration_pill_bar::render_static_agen
3434
use crate::ai::blocklist::block::model::AIBlockModel;
3535
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
3636
use crate::ai::blocklist::block::AIBlock;
37+
use crate::ai::blocklist::inline_action::host_picker::{HostPicker, HostPickerEvent};
3738
use crate::ai::blocklist::inline_action::inline_action_header::{HeaderConfig, InteractionMode};
3839
use crate::ai::blocklist::inline_action::inline_action_icons;
3940
use crate::ai::blocklist::inline_action::orchestration_controls::{
@@ -146,9 +147,6 @@ impl OrchestrationControlAction for RunAgentsCardViewAction {
146147
fn environment_changed(environment_id: String) -> Self {
147148
Self::EnvironmentChanged { environment_id }
148149
}
149-
fn worker_host_changed(worker_host: String) -> Self {
150-
Self::WorkerHostChanged { worker_host }
151-
}
152150
fn auth_secret_changed(auth_secret_name: Option<String>) -> Self {
153151
Self::AuthSecretChanged { auth_secret_name }
154152
}
@@ -254,9 +252,13 @@ fn resolve_interactive_defaults(
254252
let needs_host = worker_host.is_empty();
255253
let needs_env = environment_id.is_empty();
256254
if needs_host {
257-
state
258-
.orch
259-
.set_worker_host(oc::ORCHESTRATION_WARP_WORKER_HOST.to_string());
255+
// Prefer the workspace default (or the dev env-var override)
256+
// over the bare "warp" fallback so self-hosted teams see
257+
// their default pre-selected. Mirrors the Oz webapp's
258+
// `HostSelector` initial-selection behavior.
259+
let default_host = oc::resolve_default_host_slug(ctx)
260+
.unwrap_or_else(|| oc::ORCHESTRATION_WARP_WORKER_HOST.to_string());
261+
state.orch.set_worker_host(default_host);
260262
}
261263
if needs_env {
262264
if let Some(default_env) = oc::resolve_default_environment_id(ctx) {
@@ -668,10 +670,27 @@ impl RunAgentsCardView {
668670
RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(),
669671
RunAgentsExecutionMode::Local => oc::ORCHESTRATION_WARP_WORKER_HOST,
670672
};
671-
let handle = oc::new_standard_picker_dropdown(&colors, ctx);
672-
Self::set_upward_menu_position(&handle, ctx);
673+
let handle = ctx.add_typed_action_view(HostPicker::new);
674+
// Open upward so the menu doesn't overlap pickers below it,
675+
// matching the other dropdowns in this card.
676+
handle.update(ctx, |picker, picker_ctx| {
677+
picker.set_menu_position(
678+
warpui::elements::PositionedElementAnchor::TopLeft,
679+
warpui::elements::ChildAnchor::BottomLeft,
680+
picker_ctx,
681+
);
682+
});
673683
oc::populate_host_picker(&handle, initial_host, ctx);
674-
Self::subscribe_picker_close(&handle, ctx);
684+
ctx.subscribe_to_view(&handle, |me, _, event, ctx| match event {
685+
HostPickerEvent::HostChanged { slug } => {
686+
ctx.dispatch_typed_action(&RunAgentsCardViewAction::WorkerHostChanged {
687+
worker_host: slug.clone(),
688+
});
689+
}
690+
HostPickerEvent::Closed => {
691+
me.refocus_after_picker_close(ctx);
692+
}
693+
});
675694
self.handles.pickers.host_picker = Some(handle);
676695
}
677696

@@ -920,6 +939,7 @@ impl TypedActionView for RunAgentsCardView {
920939
}
921940
RunAgentsCardViewAction::WorkerHostChanged { worker_host } => {
922941
self.state.orch.set_worker_host(worker_host.clone());
942+
oc::persist_host_selection(worker_host, ctx);
923943
ctx.notify();
924944
}
925945
RunAgentsCardViewAction::AuthSecretChanged { auth_secret_name } => {

0 commit comments

Comments
 (0)