diff --git a/app/src/ai/blocklist/inline_action/host_picker.rs b/app/src/ai/blocklist/inline_action/host_picker.rs index f2b0ab448e..4c2eaaebf1 100644 --- a/app/src/ai/blocklist/inline_action/host_picker.rs +++ b/app/src/ai/blocklist/inline_action/host_picker.rs @@ -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, @@ -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 }, @@ -72,6 +76,8 @@ pub struct HostPicker { default_host: Option, /// User's most-recent custom host, deduped against warp / default. recent_host: Option, + /// Currently connected self-hosted worker slugs. + connected_hosts: Vec, dropdown: ViewHandle>, editor: ViewHandle, clear_mouse_state: MouseStateHandle, @@ -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(); } }); @@ -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(), @@ -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, recent_host: Option, + connected_hosts: Vec, 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.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(); @@ -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) { - 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); }); @@ -390,13 +417,15 @@ 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>> { let mut items: Vec>> = Vec::new(); + let mut known_slugs: Vec = Vec::new(); if let Some(slug) = default_host { items.push(menu_item_for_known( @@ -404,14 +433,34 @@ pub(crate) fn build_menu_items( 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. 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 2347419770..b059d541ce 100644 --- a/app/src/ai/blocklist/inline_action/host_picker_tests.rs +++ b/app/src/ai/blocklist/inline_action/host_picker_tests.rs @@ -29,7 +29,7 @@ fn item_action(item: &MenuItem>) -> &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}"); @@ -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); @@ -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). @@ -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); @@ -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) => {} diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index 9bfd6a3b8e..f9270d866f 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -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; @@ -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.; @@ -847,8 +848,17 @@ pub fn populate_host_picker( } 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); }); } @@ -1378,6 +1388,13 @@ pub fn apply_execution_mode_change( 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); } @@ -1445,6 +1462,13 @@ pub fn repopulate_all_pickers( 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); } 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 e61db71b77..b1eb8fe6f5 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 @@ -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, }; @@ -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. @@ -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(), diff --git a/app/src/ai/connected_self_hosted_workers.rs b/app/src/ai/connected_self_hosted_workers.rs new file mode 100644 index 0000000000..8cb11669d8 --- /dev/null +++ b/app/src/ai/connected_self_hosted_workers.rs @@ -0,0 +1,125 @@ +use warpui::{Entity, ModelContext, SingletonEntity}; + +use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; +use crate::auth::AuthStateProvider; +use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind}; +use crate::report_error; +use crate::server::server_api::ai::ConnectedSelfHostedWorker; +use crate::server::server_api::ServerApiProvider; +use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; +pub const WARP_WORKER_HOST: &str = "warp"; + +pub enum ConnectedSelfHostedWorkersEvent { + Changed, +} + +pub struct ConnectedSelfHostedWorkersModel { + workers: Vec, +} + +impl ConnectedSelfHostedWorkersModel { + pub fn new(ctx: &mut ModelContext) -> Self { + ctx.subscribe_to_model(&NetworkStatus::handle(ctx), |me, event, ctx| { + if let NetworkStatusEvent::NetworkStatusChanged { + new_status: NetworkStatusKind::Online, + } = event + { + me.refresh(ctx); + } + }); + + ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, event, ctx| match event { + AuthManagerEvent::AuthComplete => { + me.refresh(ctx); + } + AuthManagerEvent::AuthFailed(_) + | AuthManagerEvent::SkippedLogin + | AuthManagerEvent::NeedsReauth => { + me.clear_workers(ctx); + } + AuthManagerEvent::CreateAnonymousUserFailed + | AuthManagerEvent::AttemptedLoginGatedFeature { .. } + | AuthManagerEvent::LoginOverrideDetected(_) + | AuthManagerEvent::MintCustomTokenFailed(_) + | AuthManagerEvent::ReceivedDeviceAuthorizationCode { .. } => {} + }); + + ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, event, ctx| { + if let UserWorkspacesEvent::TeamsChanged = event { + me.refresh(ctx); + } + }); + + let mut me = Self { + workers: Vec::new(), + }; + me.refresh(ctx); + me + } + + pub fn worker_hosts_excluding(&self, excluded: Option<&str>) -> Vec { + let mut hosts: Vec = self + .workers + .iter() + .map(|worker| worker.worker_host.clone()) + .filter(|host| !host.trim().is_empty()) + .filter(|host| !host.eq_ignore_ascii_case(WARP_WORKER_HOST)) + .filter(|host| match excluded { + Some(excluded) => !host.eq_ignore_ascii_case(excluded), + None => true, + }) + .collect(); + hosts.sort(); + hosts.dedup(); + hosts + } + + pub fn refresh(&mut self, ctx: &mut ModelContext) { + if !AuthStateProvider::as_ref(ctx).get().is_logged_in() { + self.clear_workers(ctx); + return; + } + + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); + ctx.spawn( + async move { ai_client.list_connected_self_hosted_workers().await }, + |me, result, ctx| match result { + Ok(response) => { + let mut workers = response.workers; + workers.sort_by(|left, right| left.worker_host.cmp(&right.worker_host)); + if workers != me.workers { + me.workers = workers; + ctx.emit(ConnectedSelfHostedWorkersEvent::Changed); + } + } + Err(e) => { + report_error!(e.context("Failed to fetch connected self-hosted workers")); + } + }, + ); + } + + fn clear_workers(&mut self, ctx: &mut ModelContext) { + if self.clear_worker_cache() { + ctx.emit(ConnectedSelfHostedWorkersEvent::Changed); + } + } + + fn clear_worker_cache(&mut self) -> bool { + if self.workers.is_empty() { + return false; + } + self.workers.clear(); + true + } +} + +impl Entity for ConnectedSelfHostedWorkersModel { + type Event = ConnectedSelfHostedWorkersEvent; +} + +impl SingletonEntity for ConnectedSelfHostedWorkersModel {} + +#[cfg(test)] +#[path = "connected_self_hosted_workers_tests.rs"] +mod tests; diff --git a/app/src/ai/connected_self_hosted_workers_tests.rs b/app/src/ai/connected_self_hosted_workers_tests.rs new file mode 100644 index 0000000000..b936bb8ab0 --- /dev/null +++ b/app/src/ai/connected_self_hosted_workers_tests.rs @@ -0,0 +1,64 @@ +use super::*; + +fn worker(worker_host: &str) -> ConnectedSelfHostedWorker { + ConnectedSelfHostedWorker { + worker_host: worker_host.to_string(), + connection_count: 1, + connected_at: "2026-05-18T19:00:00Z".to_string(), + last_seen_at: "2026-05-18T19:05:00Z".to_string(), + } +} + +#[test] +fn worker_hosts_excluding_sorts_dedups_and_filters_empty_and_warp_hosts() { + let model = ConnectedSelfHostedWorkersModel { + workers: vec![ + worker("worker-2"), + worker(""), + worker("warp"), + worker("WARP"), + worker("worker-1"), + worker("worker-2"), + ], + }; + + assert_eq!( + model.worker_hosts_excluding(None), + vec!["worker-1".to_string(), "worker-2".to_string()] + ); +} + +#[test] +fn worker_hosts_excluding_filters_excluded_host() { + let model = ConnectedSelfHostedWorkersModel { + workers: vec![ + worker("default-host"), + worker("worker-1"), + worker("worker-2"), + ], + }; + + assert_eq!( + model.worker_hosts_excluding(Some("default-host")), + vec!["worker-1".to_string(), "worker-2".to_string()] + ); +} + +#[test] +fn clear_worker_cache_removes_cached_hosts() { + let mut model = ConnectedSelfHostedWorkersModel { + workers: vec![worker("private-host")], + }; + + assert!(model.clear_worker_cache()); + assert!(model.worker_hosts_excluding(None).is_empty()); +} + +#[test] +fn clear_worker_cache_is_noop_when_empty() { + let mut model = ConnectedSelfHostedWorkersModel { + workers: Vec::new(), + }; + + assert!(!model.clear_worker_cache()); +} diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index c549f76fbd..55d615be62 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -36,6 +36,9 @@ use crate::ai::blocklist::telemetry::{ OrchestrationExecutionModeKind, OrchestrationHarnessKind, PlanConfigApprovalToggledEvent, }; use crate::ai::blocklist::BlocklistAIHistoryEvent; +use crate::ai::connected_self_hosted_workers::{ + ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, +}; use crate::ai::document::ai_document_model::AIDocumentModel; use crate::ai::harness_availability::{ AuthSecretFetchState, HarnessAvailabilityEvent, HarnessAvailabilityModel, @@ -300,6 +303,17 @@ impl OrchestrationConfigBlockView { } }); + ctx.subscribe_to_model( + &ConnectedSelfHostedWorkersModel::handle(ctx), + |me, _, event, ctx| match event { + ConnectedSelfHostedWorkersEvent::Changed => { + if me.pickers_initialized { + oc::repopulate_all_pickers(&mut me.edit_state, &me.pickers, ctx); + } + ctx.notify(); + } + }, + ); let mut view = Self { conversation_id, plan_id, @@ -495,12 +509,18 @@ impl OrchestrationConfigBlockView { 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.subscribe_to_view(&host_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(&OrchestrationConfigBlockAction::WorkerHostChanged { worker_host: slug.clone(), }); } + HostPickerEvent::Closed => {} }); self.pickers.host_picker = Some(host_handle); diff --git a/app/src/ai/mod.rs b/app/src/ai/mod.rs index 3916d98a02..19fe91b296 100644 --- a/app/src/ai/mod.rs +++ b/app/src/ai/mod.rs @@ -46,6 +46,7 @@ pub mod agent_sdk; pub mod cloud_agent_config; pub mod cloud_agent_settings; pub mod cloud_environments; +pub mod connected_self_hosted_workers; pub mod execution_profiles; pub mod facts; pub(crate) mod generate_block_title; diff --git a/app/src/lib.rs b/app/src/lib.rs index 2974d40afb..eb4b29b71d 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -199,6 +199,7 @@ use workflows::manager::WorkflowManager; use crate::ai::agent::conversation::AIConversationId; use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier; +use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; use crate::ai::document::ai_document_model::AIDocumentModel; use crate::ai::facts::manager::AIFactManager; use crate::ai::harness_availability::HarnessAvailabilityModel; @@ -1895,6 +1896,7 @@ pub(crate) fn initialize_app( ctx.add_singleton_model(LLMPreferences::new); ctx.add_singleton_model(HarnessAvailabilityModel::new); + ctx.add_singleton_model(ConnectedSelfHostedWorkersModel::new); let tip_model_handle = ctx.add_singleton_model(|ctx| { ai::agent_tips::AITipModel::::new_for_agent_tips(ctx) diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index 875481ceba..a44da0336f 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -853,6 +853,21 @@ struct ListAgentsResponse { agents: Vec, } +#[derive(Clone, serde::Deserialize, Debug, PartialEq, Eq)] +pub struct ConnectedSelfHostedWorker { + pub worker_host: String, + pub connection_count: u32, + pub connected_at: String, + pub last_seen_at: String, +} + +#[derive(Clone, serde::Deserialize, Debug, PartialEq, Eq)] +pub struct ListConnectedSelfHostedWorkersResponse { + pub workers: Vec, +} + +pub(crate) const CONNECTED_SELF_HOSTED_WORKERS_PATH: &str = "agent/connected-self-hosted-workers"; + #[cfg_attr(test, automock)] #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] @@ -880,6 +895,9 @@ pub trait AIClient: 'static + Send + Sync { async fn get_feature_model_choices(&self) -> Result; async fn get_available_harnesses(&self) -> Result, anyhow::Error>; + async fn list_connected_self_hosted_workers( + &self, + ) -> Result; /// Fetches the free-tier available models without requiring authentication. /// Used during pre-login onboarding so logged-out users see an accurate model list @@ -1701,6 +1719,13 @@ impl AIClient for ServerApi { Ok(response) } + async fn list_connected_self_hosted_workers( + &self, + ) -> anyhow::Result { + self.get_public_api(CONNECTED_SELF_HOSTED_WORKERS_PATH) + .await + } + async fn upload_local_handoff_snapshot( &self, request: UploadLocalHandoffSnapshotRequest, diff --git a/app/src/server/server_api/ai_tests.rs b/app/src/server/server_api/ai_tests.rs index 15a5b6088c..04cabc8f6c 100644 --- a/app/src/server/server_api/ai_tests.rs +++ b/app/src/server/server_api/ai_tests.rs @@ -7,9 +7,10 @@ use super::super::ServerApi; use super::{ build_fork_conversation_url, build_list_agent_runs_url, build_run_followup_url, AgentMessageHeader, AgentRunEvent, AgentSource, AmbientAgentTaskState, Artifact, - ArtifactDownloadResponse, ArtifactType, ExecutionLocation, ForkConversationResponse, - ListRunsResponse, ReadAgentMessageResponse, RunFollowupRequest, RunSortBy, RunSortOrder, - SpawnAgentRequest, TaskListFilter, UserQueryMode, + ArtifactDownloadResponse, ArtifactType, ConnectedSelfHostedWorker, ExecutionLocation, + ForkConversationResponse, ListConnectedSelfHostedWorkersResponse, ListRunsResponse, + ReadAgentMessageResponse, RunFollowupRequest, RunSortBy, RunSortOrder, SpawnAgentRequest, + TaskListFilter, UserQueryMode, CONNECTED_SELF_HOSTED_WORKERS_PATH, }; use crate::notebooks::NotebookId; @@ -63,6 +64,53 @@ fn spawn_agent_request_serializes_agent_uid_as_agent_identity_uid() { assert!(value.get("agent_uid").is_none()); } +#[test] +fn connected_self_hosted_workers_path_uses_public_api_route() { + assert_eq!( + CONNECTED_SELF_HOSTED_WORKERS_PATH, + "agent/connected-self-hosted-workers" + ); +} + +#[test] +fn deserialize_connected_self_hosted_workers_response() { + let json = r#"{ + "workers": [ + { + "worker_host": "worker-2", + "connection_count": 2, + "connected_at": "2026-05-18T19:00:00Z", + "last_seen_at": "2026-05-18T19:05:00Z" + }, + { + "worker_host": "worker-1", + "connection_count": 1, + "connected_at": "2026-05-18T18:00:00Z", + "last_seen_at": "2026-05-18T18:05:00Z" + } + ] + }"#; + + let response: ListConnectedSelfHostedWorkersResponse = serde_json::from_str(json).unwrap(); + + assert_eq!( + response.workers, + vec![ + ConnectedSelfHostedWorker { + worker_host: "worker-2".to_string(), + connection_count: 2, + connected_at: "2026-05-18T19:00:00Z".to_string(), + last_seen_at: "2026-05-18T19:05:00Z".to_string(), + }, + ConnectedSelfHostedWorker { + worker_host: "worker-1".to_string(), + connection_count: 1, + connected_at: "2026-05-18T18:00:00Z".to_string(), + last_seen_at: "2026-05-18T18:05:00Z".to_string(), + }, + ] + ); +} #[test] fn test_deserialize_file_artifact_download_response() { let json = r#"{ diff --git a/app/src/terminal/view/ambient_agent/host_selector.rs b/app/src/terminal/view/ambient_agent/host_selector.rs index dfe495e904..b589e0058d 100644 --- a/app/src/terminal/view/ambient_agent/host_selector.rs +++ b/app/src/terminal/view/ambient_agent/host_selector.rs @@ -17,7 +17,9 @@ use warp_core::ui::theme::Fill; use settings::Setting as _; +use crate::ai::blocklist::inline_action::orchestration_controls::ORCHESTRATION_WARP_WORKER_HOST; use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; use crate::report_if_error; use crate::terminal::input::{MenuPositioning, MenuPositioningProvider}; @@ -58,7 +60,7 @@ impl Host { /// Returns the value to send as `worker_host` in the config snapshot. pub fn worker_host_value(&self) -> Option { match self { - Host::Warp => Some("warp".to_string()), + Host::Warp => Some(ORCHESTRATION_WARP_WORKER_HOST.to_string()), Host::SelfHosted { slug } => Some(slug.clone()), } } @@ -125,6 +127,12 @@ impl HostSelector { ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, _, ctx| { me.refresh_menu(ctx); }); + ctx.subscribe_to_model( + &ConnectedSelfHostedWorkersModel::handle(ctx), + |me, _, _, ctx| { + me.refresh_menu(ctx); + }, + ); let mut me = Self { button, @@ -140,7 +148,7 @@ impl HostSelector { .value() .as_deref() { - let restored = if saved_slug == "warp" { + let restored = if saved_slug == ORCHESTRATION_WARP_WORKER_HOST { Host::Warp } else { Host::SelfHosted { @@ -216,6 +224,9 @@ impl HostSelector { } self.is_menu_open = is_open; if is_open { + ConnectedSelfHostedWorkersModel::handle(ctx).update(ctx, |model, ctx| { + model.refresh(ctx); + }); ctx.focus(&self.menu); self.highlight_selected_host(ctx); } @@ -233,6 +244,8 @@ impl HostSelector { hover_background, header_text_color, self.default_host.as_ref(), + &self.selected, + ctx, ); self.menu.update(ctx, |menu, ctx| { menu.set_border(Some(border)); @@ -262,6 +275,8 @@ fn build_menu_items( hover_background: Fill, header_text_color: ColorU, default_host: Option<&Host>, + selected: &Host, + ctx: &mut ViewContext, ) -> Vec> { let header = MenuItem::Header { fields: MenuItemFields::new(MENU_HEADER_LABEL) @@ -289,6 +304,24 @@ fn build_menu_items( items.push(item_for(host.clone())); } items.push(item_for(Host::Warp)); + let default_slug = match default_host { + Some(Host::SelfHosted { slug }) => Some(slug.as_str()), + Some(Host::Warp) | None => None, + }; + let mut connected_hosts = ConnectedSelfHostedWorkersModel::as_ref(ctx) + .worker_hosts_excluding(default_slug) + .into_iter() + .collect::>(); + if let Host::SelfHosted { slug } = selected { + if default_slug != Some(slug.as_str()) { + connected_hosts.push(slug.clone()); + } + } + connected_hosts.sort(); + connected_hosts.dedup(); + for host in connected_hosts { + items.push(item_for(Host::SelfHosted { slug: host })); + } items } diff --git a/app/src/test_util/terminal.rs b/app/src/test_util/terminal.rs index 6aff2bc867..0f17c0d44d 100644 --- a/app/src/test_util/terminal.rs +++ b/app/src/test_util/terminal.rs @@ -28,6 +28,7 @@ use crate::ai::blocklist::orchestration_events::OrchestrationEventService; use crate::ai::blocklist::task_status_sync_model::TaskStatusSyncModel; use crate::ai::blocklist::BlocklistAIPermissions; use crate::ai::blocklist::SerializedBlockListItem; +use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::harness_availability::HarnessAvailabilityModel; use crate::ai::llms::LLMPreferences; @@ -124,6 +125,7 @@ pub fn initialize_app_for_terminal_view(app: &mut App) { app.add_singleton_model(LLMPreferences::new); app.add_singleton_model(HarnessAvailabilityModel::new); app.add_singleton_model(|ctx| AITipModel::new_for_agent_tips(ctx)); + app.add_singleton_model(ConnectedSelfHostedWorkersModel::new); app.add_singleton_model(SessionPermissionsManager::new); app.add_singleton_model(DirectoryWatcher::new); app.add_singleton_model(|_| DetectedRepositories::default());