Skip to content

Commit 10ec3d1

Browse files
authored
Hide host selector menu if no default host is present. (#9523)
## Description <!-- Please remember to add your design buddy onto the PR for review, if it contains any UI changes! --> Completes REMOTE-1535. ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? If you're not sure whether you should add a test, check our testing policy: https://www.notion.so/warpdev/How-We-Code-at-Warp-257fe43d556e4b3c8dfd42f70004cc72#1f97825450504baa9c5fd87a737daa09 --> https://www.loom.com/share/2df54624aa9846c3b2c9896df6d8caa6 ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode
1 parent 91dee6d commit 10ec3d1

12 files changed

Lines changed: 179 additions & 30 deletions

File tree

‎app/src/terminal/input.rs‎

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ use crate::{
221221
ForkedConversationDestination, InitContent, RestoreConversationLayout, ToastStack,
222222
WorkspaceAction,
223223
},
224-
workspaces::user_workspaces::UserWorkspaces,
224+
workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent},
225225
AgentModeEntrypoint, ServerApiProvider,
226226
};
227227

@@ -2220,17 +2220,78 @@ impl Input {
22202220
let view = ctx.add_typed_action_view(|ctx| {
22212221
HostSelector::new(menu_positioning_provider.clone(), ctx)
22222222
});
2223-
// Mirror the V2 model selector's `ModelSelectorClosed` -> refocus path:
2224-
// when the host selector menu closes (item picked or dismissed via Esc /
2225-
// click-outside), restore focus to the input editor so typing resumes
2226-
// immediately. This is what powers the "input is focused after the host
2227-
// selector closes" UX for the `/harness` slash command.
2223+
// Env var takes priority over workspace setting for developer testing.
2224+
let effective_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
2225+
.ok()
2226+
.filter(|s| !s.is_empty())
2227+
.or_else(|| {
2228+
UserWorkspaces::as_ref(ctx)
2229+
.default_host_slug()
2230+
.map(String::from)
2231+
});
2232+
if let Some(slug) = &effective_host {
2233+
view.update(ctx, |selector, ctx| {
2234+
selector.set_default_host(slug.clone(), ctx);
2235+
});
2236+
}
2237+
if let Some(slug) = effective_host {
2238+
view_model.update(ctx, |model, _ctx| {
2239+
model.set_worker_host(Some(slug));
2240+
});
2241+
}
2242+
// When the host selector menu closes (item picked or dismissed via
2243+
// Esc / click-outside), restore focus to the input editor so typing
2244+
// resumes immediately.
22282245
ctx.subscribe_to_view(&view, |me, _, event, ctx| {
2229-
let HostSelectorEvent::MenuVisibilityChanged { open } = event;
2230-
if !*open {
2246+
if matches!(
2247+
event,
2248+
HostSelectorEvent::MenuVisibilityChanged { open: false }
2249+
) {
22312250
me.focus_input_box(ctx);
22322251
}
22332252
});
2253+
// Propagate host selection changes to the view model when a host is
2254+
// explicitly selected, rather than on menu close, to avoid a race
2255+
// where the menu closes before the selection updates.
2256+
let vm_for_host = view_model.clone();
2257+
ctx.subscribe_to_view(&view, move |_me, handle, event, ctx| {
2258+
if matches!(event, HostSelectorEvent::HostSelected) {
2259+
let selected = handle.as_ref(ctx).selected().clone();
2260+
vm_for_host.update(ctx, |model, _ctx| {
2261+
model.set_worker_host(selected.worker_host_value());
2262+
});
2263+
}
2264+
});
2265+
// Keep the host selector and view model in sync when workspace
2266+
// metadata refreshes (e.g. admin changes default_host_slug).
2267+
let view_for_ws = view.clone();
2268+
let vm_for_ws = view_model.clone();
2269+
ctx.subscribe_to_model(
2270+
&UserWorkspaces::handle(ctx),
2271+
move |_me, _, event, ctx| {
2272+
if !matches!(event, UserWorkspacesEvent::TeamsChanged) {
2273+
return;
2274+
}
2275+
let effective_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
2276+
.ok()
2277+
.filter(|s| !s.is_empty())
2278+
.or_else(|| {
2279+
UserWorkspaces::as_ref(ctx)
2280+
.default_host_slug()
2281+
.map(String::from)
2282+
});
2283+
if let Some(slug) = &effective_host {
2284+
view_for_ws.update(ctx, |selector, ctx| {
2285+
selector.set_default_host(slug.clone(), ctx);
2286+
});
2287+
}
2288+
if let Some(slug) = effective_host {
2289+
vm_for_ws.update(ctx, |model, _ctx| {
2290+
model.set_worker_host(Some(slug));
2291+
});
2292+
}
2293+
},
2294+
);
22342295
Some(view)
22352296
} else {
22362297
None

‎app/src/terminal/input/agent.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ impl Input {
506506
.with_main_axis_size(MainAxisSize::Min)
507507
.with_spacing(CLOUD_MODE_V2_TOP_ROW_GAP);
508508

509-
column.add_child(self.render_cloud_mode_v2_top_row());
509+
column.add_child(self.render_cloud_mode_v2_top_row(app));
510510
column.add_child(self.render_cloud_mode_v2_input_container(appearance, app));
511511
Align::new(
512512
ConstrainedBox::new(column.finish())
@@ -528,14 +528,17 @@ impl Input {
528528
Some(ChildView::new(view).finish())
529529
}
530530

531-
fn render_cloud_mode_v2_top_row(&self) -> Box<dyn Element> {
531+
fn render_cloud_mode_v2_top_row(&self, app: &AppContext) -> Box<dyn Element> {
532532
let mut row = Flex::row()
533533
.with_main_axis_size(MainAxisSize::Min)
534534
.with_cross_axis_alignment(CrossAxisAlignment::Center)
535535
.with_spacing(CLOUD_MODE_V2_TOP_ROW_INNER_GAP);
536536

537+
// Only show the host selector when a default host is configured.
537538
if let Some(host) = self.host_selector() {
538-
row.add_child(ChildView::new(host).finish());
539+
if host.as_ref(app).has_default_host() {
540+
row.add_child(ChildView::new(host).finish());
541+
}
539542
}
540543
if let Some(harness_selector) = self.harness_selector() {
541544
row.add_child(ChildView::new(harness_selector).finish());

‎app/src/terminal/input/slash_commands/data_source/mod.rs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ impl SlashCommandDataSource {
120120
}
121121
});
122122
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, event, ctx| {
123-
if matches!(event, UserWorkspacesEvent::CodebaseContextEnablementChanged) {
123+
if matches!(
124+
event,
125+
UserWorkspacesEvent::CodebaseContextEnablementChanged
126+
| UserWorkspacesEvent::TeamsChanged
127+
) {
124128
me.recompute_active_commands(ctx);
125129
}
126130
});
@@ -246,6 +250,13 @@ impl SlashCommandDataSource {
246250

247251
let is_orchestration_enabled = AISettings::as_ref(ctx).is_orchestration_enabled(ctx);
248252

253+
// Hide /host when no default host is configured (env var or workspace setting).
254+
let has_default_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
255+
.ok()
256+
.filter(|s| !s.is_empty())
257+
.is_some()
258+
|| UserWorkspaces::as_ref(ctx).default_host_slug().is_some();
259+
249260
#[cfg(not(target_family = "wasm"))]
250261
let active_conversation_is_cloud_oz = self.active_conversation_is_cloud_oz(ctx);
251262

@@ -279,6 +290,8 @@ impl SlashCommandDataSource {
279290
true
280291
}
281292
})
293+
// /host is only useful when a default self-hosted host is configured.
294+
.filter(|(_, command)| command.name != commands::HOST.name || has_default_host)
282295
// When CLI agent input is open, restrict to the explicit allowlist.
283296
.filter(|(_, command)| {
284297
!is_cli_agent_input

‎app/src/terminal/input/slash_commands/mod.rs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -711,8 +711,13 @@ impl Input {
711711
}
712712
host if command.name == commands::HOST.name => {
713713
if !self.is_cloud_mode_input_v2_composing(ctx) {
714-
// Defensive: the command is registered only when the V2 flag is on and its
715-
// availability requires CLOUD_AGENT_V2, so this branch should be unreachable.
714+
return false;
715+
}
716+
// Only open the host selector when a default host is configured.
717+
if self
718+
.host_selector()
719+
.is_none_or(|h| !h.as_ref(ctx).has_default_host())
720+
{
716721
return false;
717722
}
718723
self.suggestions_mode_model.update(ctx, |model, ctx| {

‎app/src/terminal/view/ambient_agent/host_selector.rs‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,25 @@ const BUTTON_TOOLTIP: &str = "Execution host";
3737

3838
const MENU_HEADER_LABEL: &str = "Execution host";
3939

40-
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40+
#[derive(Clone, Debug, PartialEq, Eq)]
4141
pub enum Host {
4242
Warp,
43+
SelfHosted { slug: String },
4344
}
4445

4546
impl Host {
46-
fn display_name(self) -> &'static str {
47+
fn display_name(&self) -> &str {
4748
match self {
4849
Host::Warp => "Warp",
50+
Host::SelfHosted { slug } => slug.as_str(),
51+
}
52+
}
53+
54+
/// Returns the value to send as `worker_host` in the config snapshot.
55+
pub fn worker_host_value(&self) -> Option<String> {
56+
match self {
57+
Host::Warp => Some("warp".to_string()),
58+
Host::SelfHosted { slug } => Some(slug.clone()),
4959
}
5060
}
5161
}
@@ -58,6 +68,7 @@ pub enum HostSelectorAction {
5868

5969
pub enum HostSelectorEvent {
6070
MenuVisibilityChanged { open: bool },
71+
HostSelected,
6172
}
6273

6374
pub struct HostSelector {
@@ -66,6 +77,8 @@ pub struct HostSelector {
6677
is_menu_open: bool,
6778
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
6879
selected: Host,
80+
/// The configured default self-hosted host, if any.
81+
default_host: Option<Host>,
6982
}
7083

7184
impl HostSelector {
@@ -78,9 +91,10 @@ impl HostSelector {
7891
// `SelectHost`), so it stays out of clippy's `field is never read`
7992
// warning while still serving as the source of truth for the label.
8093
let selected = Host::Warp;
94+
let initial_label = selected.display_name().to_string();
8195

8296
let button = ctx.add_typed_action_view(|_ctx| {
83-
ActionButton::new(selected.display_name(), NakedHeaderButtonTheme)
97+
ActionButton::new(initial_label, NakedHeaderButtonTheme)
8498
.with_size(ButtonSize::AgentInputButton)
8599
.with_menu(true)
86100
.with_tooltip(BUTTON_TOOLTIP)
@@ -114,6 +128,7 @@ impl HostSelector {
114128
is_menu_open: false,
115129
menu_positioning_provider,
116130
selected,
131+
default_host: None,
117132
};
118133
me.refresh_menu(ctx);
119134
me
@@ -123,6 +138,25 @@ impl HostSelector {
123138
self.is_menu_open
124139
}
125140

141+
pub fn has_default_host(&self) -> bool {
142+
self.default_host.is_some()
143+
}
144+
145+
pub fn selected(&self) -> &Host {
146+
&self.selected
147+
}
148+
149+
pub fn set_default_host(&mut self, slug: String, ctx: &mut ViewContext<Self>) {
150+
let host = Host::SelfHosted { slug };
151+
let label = host.display_name().to_string();
152+
self.selected = host.clone();
153+
self.button.update(ctx, |button, ctx| {
154+
button.set_label(label.clone(), ctx);
155+
});
156+
self.default_host = Some(host);
157+
self.refresh_menu(ctx);
158+
}
159+
126160
/// Programmatically opens the host selector popover. No-op if already open.
127161
pub fn open_menu(&mut self, ctx: &mut ViewContext<Self>) {
128162
self.set_menu_visibility(true, ctx);
@@ -132,7 +166,7 @@ impl HostSelector {
132166
/// from closed to open so the user has a clear starting point for arrow-key navigation
133167
/// instead of an unselected list.
134168
fn highlight_selected_host(&mut self, ctx: &mut ViewContext<Self>) {
135-
let selected_action = HostSelectorAction::SelectHost(self.selected);
169+
let selected_action = HostSelectorAction::SelectHost(self.selected.clone());
136170
self.menu.update(ctx, |menu, ctx| {
137171
menu.set_selected_by_action(&selected_action, ctx);
138172
});
@@ -157,7 +191,11 @@ impl HostSelector {
157191
let hover_background: Fill = internal_colors::neutral_4(theme).into();
158192
let header_text_color = theme.disabled_text_color(theme.surface_2()).into_solid();
159193
let border = Border::all(1.).with_border_fill(theme.outline());
160-
let items = build_menu_items(hover_background, header_text_color);
194+
let items = build_menu_items(
195+
hover_background,
196+
header_text_color,
197+
self.default_host.as_ref(),
198+
);
161199
self.menu.update(ctx, |menu, ctx| {
162200
menu.set_border(Some(border));
163201
menu.set_items(items, ctx);
@@ -185,6 +223,7 @@ impl HostSelector {
185223
fn build_menu_items(
186224
hover_background: Fill,
187225
header_text_color: ColorU,
226+
default_host: Option<&Host>,
188227
) -> Vec<MenuItem<HostSelectorAction>> {
189228
let header = MenuItem::Header {
190229
fields: MenuItemFields::new(MENU_HEADER_LABEL)
@@ -197,16 +236,22 @@ fn build_menu_items(
197236
};
198237

199238
let item_for = |host: Host| {
239+
let label = host.display_name().to_string();
200240
MenuItem::Item(
201-
MenuItemFields::new(host.display_name())
241+
MenuItemFields::new(label)
202242
.with_font_size_override(ITEM_FONT_SIZE)
203243
.with_padding_override(ITEM_VERTICAL_PADDING, MENU_HORIZONTAL_PADDING)
204244
.with_override_hover_background_color(hover_background)
205245
.with_on_select_action(HostSelectorAction::SelectHost(host)),
206246
)
207247
};
208248

209-
vec![header, item_for(Host::Warp)]
249+
let mut items = vec![header];
250+
if let Some(host) = default_host {
251+
items.push(item_for(host.clone()));
252+
}
253+
items.push(item_for(Host::Warp));
254+
items
210255
}
211256

212257
impl Entity for HostSelector {
@@ -223,11 +268,12 @@ impl TypedActionView for HostSelector {
223268
self.set_menu_visibility(new_state, ctx);
224269
}
225270
HostSelectorAction::SelectHost(host) => {
226-
self.selected = *host;
227-
let label = self.selected.display_name();
271+
self.selected = host.clone();
272+
let label = self.selected.display_name().to_string();
228273
self.button.update(ctx, |button, ctx| {
229-
button.set_label(label, ctx);
274+
button.set_label(label.clone(), ctx);
230275
});
276+
ctx.emit(HostSelectorEvent::HostSelected);
231277
self.set_menu_visibility(false, ctx);
232278
}
233279
}

‎app/src/terminal/view/ambient_agent/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ pub fn create_cloud_mode_view(
9898
| AmbientAgentViewModelEvent::NeedsGithubAuth
9999
| AmbientAgentViewModelEvent::Cancelled
100100
| AmbientAgentViewModelEvent::HarnessSelected
101+
| AmbientAgentViewModelEvent::HostSelected
101102
| AmbientAgentViewModelEvent::HarnessCommandStarted
102103
| AmbientAgentViewModelEvent::UpdatedSetupCommandVisibility => {}
103104
}

0 commit comments

Comments
 (0)