Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 64 additions & 15 deletions app/src/ai/blocklist/inline_action/host_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
//! 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.
//! then connected worker hosts, then the user's most recent custom slug,
//! then a "Custom host…" entry.

use warpui::elements::{
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Expand Down Expand Up @@ -38,6 +39,9 @@ use crate::view_components::dropdown::{

#[derive(Debug, Clone)]
pub enum HostPickerEvent {
/// Emitted when the dropdown opens so the parent can refresh dynamic
/// host sources before/while the menu is visible.
Opened,
/// Emitted with a non-empty, trimmed slug whenever the user picks a
/// known host or commits a custom entry.
HostChanged { slug: String },
Expand Down Expand Up @@ -72,6 +76,8 @@ pub struct HostPicker {
default_host: Option<String>,
/// User's most-recent custom host, deduped against warp / default.
recent_host: Option<String>,
/// Currently connected self-hosted worker slugs.
connected_hosts: Vec<String>,
dropdown: ViewHandle<Dropdown<InternalAction>>,
editor: ViewHandle<EditorView>,
clear_mouse_state: MouseStateHandle,
Expand Down Expand Up @@ -101,16 +107,21 @@ impl HostPicker {
dropdown
});
ctx.subscribe_to_view(&dropdown, |me, _, event, ctx| {
if let DropdownEvent::Close = event {
// Don't propagate Closed while transitioning into custom
// mode — the parent would refocus itself, blur the editor
// we just focused, and the resulting commit-on-blur would
// immediately revert us back out of custom mode.
if me.is_custom_mode {
return;
match event {
DropdownEvent::ToggleExpanded => {
ctx.emit(HostPickerEvent::Opened);
}
DropdownEvent::Close => {
// Don't propagate Closed while transitioning into custom
// mode — the parent would refocus itself, blur the editor
// we just focused, and the resulting commit-on-blur would
// immediately revert us back out of custom mode.
if me.is_custom_mode {
return;
}
ctx.emit(HostPickerEvent::Closed);
ctx.notify();
}
ctx.emit(HostPickerEvent::Closed);
ctx.notify();
}
});

Expand All @@ -137,6 +148,7 @@ impl HostPicker {
current_slug: ORCHESTRATION_WARP_WORKER_HOST.to_string(),
default_host: None,
recent_host: None,
connected_hosts: Vec::new(),
dropdown,
editor,
clear_mouse_state: MouseStateHandle::default(),
Expand All @@ -150,15 +162,23 @@ impl HostPicker {

// ── Public API ──────────────────────────────────────────────────

/// Replaces the default and recent menu rows. Pass `None` to omit one.
/// Replaces the default, connected, and recent menu rows. Pass `None` to omit
/// default/recent rows.
pub fn set_options(
&mut self,
default_host: Option<String>,
recent_host: Option<String>,
connected_hosts: Vec<String>,
ctx: &mut ViewContext<Self>,
) {
self.default_host = default_host.filter(|s| !s.trim().is_empty());
self.recent_host = recent_host.filter(|s| !s.trim().is_empty());
self.connected_hosts = connected_hosts
.into_iter()
.filter(|s| !s.trim().is_empty())
.collect();
self.connected_hosts.sort();
self.connected_hosts.dedup();
self.repopulate_menu(ctx);
self.sync_dropdown_selection(ctx);
ctx.notify();
Expand Down Expand Up @@ -211,11 +231,18 @@ impl HostPicker {
if self.recent_host.as_deref() == Some(slug) {
return true;
}
if self.connected_hosts.iter().any(|host| host == slug) {
return true;
}
false
}

fn repopulate_menu(&mut self, ctx: &mut ViewContext<Self>) {
let items = build_menu_items(self.default_host.as_deref(), self.recent_host.as_deref());
let items = build_menu_items(
self.default_host.as_deref(),
self.recent_host.as_deref(),
&self.connected_hosts,
);
self.dropdown.update(ctx, |dropdown, ctx_dropdown| {
dropdown.set_rich_items(items, ctx_dropdown);
});
Expand Down Expand Up @@ -390,28 +417,50 @@ fn normalize_slug(slug: &str) -> 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.
/// (badged "Default" if set), warp, connected worker hosts, 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>,
connected_hosts: &[String],
) -> Vec<MenuItem<DropdownAction<InternalAction>>> {
let mut items: Vec<MenuItem<DropdownAction<InternalAction>>> = Vec::new();
let mut known_slugs: Vec<String> = Vec::new();

if let Some(slug) = default_host {
items.push(menu_item_for_known(
slug,
Some(DEFAULT_BADGE),
InternalAction::SelectKnown(slug.to_string()),
));
known_slugs.push(slug.to_string());
}
items.push(menu_item_for_known(
ORCHESTRATION_WARP_WORKER_HOST,
None,
InternalAction::SelectKnown(ORCHESTRATION_WARP_WORKER_HOST.to_string()),
));
known_slugs.push(ORCHESTRATION_WARP_WORKER_HOST.to_string());

for slug in connected_hosts {
if slug.trim().is_empty()
|| known_slugs
.iter()
.any(|known| known.eq_ignore_ascii_case(slug))
{
continue;
}
items.push(menu_item_for_known(
slug,
None,
InternalAction::SelectKnown(slug.to_string()),
));
known_slugs.push(slug.to_string());
}
if let Some(slug) = recent_host {
if default_host != Some(slug) && !slug.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST)
if !known_slugs
.iter()
.any(|known| known.eq_ignore_ascii_case(slug))
{
// Recent hosts render as plain slugs; only the workspace
// default carries a badge.
Expand Down
31 changes: 24 additions & 7 deletions app/src/ai/blocklist/inline_action/host_picker_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ fn item_action(item: &MenuItem<DropdownAction<InternalAction>>) -> &DropdownActi

#[test]
fn build_menu_items_with_no_defaults_shows_warp_and_custom() {
let items = build_menu_items(None, None);
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}");
Expand All @@ -39,7 +39,7 @@ fn build_menu_items_with_no_defaults_shows_warp_and_custom() {
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);
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);
Expand All @@ -48,7 +48,7 @@ fn build_menu_items_promotes_default_to_top() {

#[test]
fn build_menu_items_adds_recent_after_warp() {
let items = build_menu_items(None, Some("other-host"));
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).
Expand All @@ -59,20 +59,37 @@ fn build_menu_items_adds_recent_after_warp() {
#[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"));
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"));
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_adds_connected_hosts_before_recent_and_dedups_known_hosts() {
let connected_hosts = vec![
"alpha".to_string(),
"warp".to_string(),
"my-corp".to_string(),
"alpha".to_string(),
"beta".to_string(),
];
let items = build_menu_items(Some("my-corp"), Some("beta"), &connected_hosts);
assert_eq!(items.len(), 5);
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]), "alpha");
assert_eq!(item_label(&items[3]), "beta");
assert_eq!(item_label(&items[4]), "Custom host\u{2026}");
}

#[test]
fn build_menu_items_warp_entry_dispatches_select_known_warp() {
let items = build_menu_items(None, None);
let items = build_menu_items(None, None, &[]);
match item_action(&items[0]) {
DropdownAction::SelectActionAndClose(InternalAction::SelectKnown(slug)) => {
assert_eq!(slug, ORCHESTRATION_WARP_WORKER_HOST);
Expand All @@ -83,7 +100,7 @@ fn build_menu_items_warp_entry_dispatches_select_known_warp() {

#[test]
fn build_menu_items_custom_entry_dispatches_enter_custom_mode() {
let items = build_menu_items(None, None);
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) => {}
Expand Down
28 changes: 26 additions & 2 deletions app/src/ai/blocklist/inline_action/orchestration_controls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ 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::connected_self_hosted_workers::{ConnectedSelfHostedWorkersModel, WARP_WORKER_HOST};
use crate::ai::execution_profiles::model_menu_items::available_model_menu_items;
use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel};
use crate::ai::harness_display;
Expand All @@ -57,7 +58,7 @@ const DEFAULT_HOST_ENV_VAR: &str = "WARP_CLOUD_MODE_DEFAULT_HOST";

// ── Shared constants ────────────────────────────────────────────────

pub const ORCHESTRATION_WARP_WORKER_HOST: &str = "warp";
pub const ORCHESTRATION_WARP_WORKER_HOST: &str = WARP_WORKER_HOST;
pub const ORCHESTRATION_ENV_NONE_LABEL: &str = "Empty environment";

pub const ORCHESTRATION_PICKER_HEIGHT: f32 = 36.;
Expand Down Expand Up @@ -847,8 +848,17 @@ pub fn populate_host_picker<V: View>(
} else {
initial_host.to_string()
};
let mut connected_hosts = ConnectedSelfHostedWorkersModel::as_ref(ctx)
.worker_hosts_excluding(default_host.as_deref());
if !initial.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST)
&& default_host.as_deref() != Some(initial.as_str())
{
connected_hosts.push(initial.clone());
}
connected_hosts.sort();
connected_hosts.dedup();
picker.update(ctx, |picker, picker_ctx| {
picker.set_options(default_host, recent_host, picker_ctx);
picker.set_options(default_host, recent_host, connected_hosts, picker_ctx);
picker.set_selected(&initial, picker_ctx);
});
}
Expand Down Expand Up @@ -1378,6 +1388,13 @@ pub fn apply_execution_mode_change<A: OrchestrationControlAction, V: View>(
ctx,
);
}
if let Some(handle) = &handles.host_picker {
let initial_host = match &state.execution_mode {
RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(),
RunAgentsExecutionMode::Local => ORCHESTRATION_WARP_WORKER_HOST,
};
populate_host_picker(handle, initial_host, ctx);
}
sync_picker_selections(state, handles, ctx);
}

Expand Down Expand Up @@ -1445,6 +1462,13 @@ pub fn repopulate_all_pickers<A: OrchestrationControlAction, V: View>(
ctx,
);
}
if let Some(handle) = &handles.host_picker {
let initial_host = match &state.execution_mode {
RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(),
RunAgentsExecutionMode::Local => ORCHESTRATION_WARP_WORKER_HOST,
};
populate_host_picker(handle, initial_host, ctx);
}
sync_picker_selections(state, handles, ctx);
}

Expand Down
18 changes: 18 additions & 0 deletions app/src/ai/blocklist/inline_action/run_agents_card_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ use crate::ai::blocklist::inline_action::orchestration_controls::{
use crate::ai::blocklist::inline_action::requested_action::{
render_requested_action_row_for_text, CTRL_C_KEYSTROKE, ENTER_KEYSTROKE,
};
use crate::ai::connected_self_hosted_workers::{
ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel,
};
use crate::ai::harness_availability::{
AuthSecretFetchState, HarnessAvailabilityEvent, HarnessAvailabilityModel,
};
Expand Down Expand Up @@ -513,6 +516,16 @@ impl RunAgentsCardView {
}
});

ctx.subscribe_to_model(
&ConnectedSelfHostedWorkersModel::handle(ctx),
|me, _, event, ctx| match event {
ConnectedSelfHostedWorkersEvent::Changed => {
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.refresh_accept_button_state(ctx);
ctx.notify();
}
},
);
// When auto_launched is true, execution is deferred to the
// ActionBlockedOnUserConfirmation subscription above — the action
// hasn't been queued in pending_actions yet at construction time.
Expand Down Expand Up @@ -910,6 +923,11 @@ impl RunAgentsCardView {
});
oc::populate_host_picker(&handle, initial_host, ctx);
ctx.subscribe_to_view(&handle, |me, _, event, ctx| match event {
HostPickerEvent::Opened => {
ConnectedSelfHostedWorkersModel::handle(ctx).update(ctx, |model, ctx| {
model.refresh(ctx);
});
}
HostPickerEvent::HostChanged { slug } => {
ctx.dispatch_typed_action(&RunAgentsCardViewAction::WorkerHostChanged {
worker_host: slug.clone(),
Expand Down
Loading
Loading