Skip to content
Open
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
8 changes: 7 additions & 1 deletion app/src/ai/orchestration/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ pub fn first_filtered_model_id(harness_type: &str, ctx: &AppContext) -> Option<S
/// 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.
///
/// Still reads ambient workspace settings rather than the window's team. Every consumer of
/// this chain — the plan card, the confirmation card, the TUI orchestration block, and the
/// handoff pipeline — has to move together, and two of them need decisions this function
/// cannot make on its own: the TUI has no window to scope to, and the handoff bakes the host
/// into a cloud run's config, which pins rather than resolves late.
pub fn resolve_default_host_slug(ctx: &AppContext) -> Option<String> {
if let Ok(slug) = std::env::var(DEFAULT_HOST_ENV_VAR) {
let trimmed = slug.trim();
Expand All @@ -95,7 +101,7 @@ pub fn resolve_default_host_slug(ctx: &AppContext) -> Option<String> {
}
}
UserWorkspaces::as_ref(ctx)
.default_host_slug()
.unscoped_default_host_slug()
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
}
Expand Down
4 changes: 2 additions & 2 deletions app/src/settings/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2069,8 +2069,8 @@ define_settings_group!(AISettings, settings: [

// Whether Oz should add attribution (co-author line) to commit messages and PRs.
// This is the user-level preference; it may be overridden by the team-level
// `enable_warp_attribution` AdminEnablementSetting (see
// `UserWorkspaces::get_agent_attribution_setting`).
// `enable_warp_attribution` AdminEnablementSetting of the window's team (see
// `UserWorkspaces::agent_attribution_setting_for_scope`).
agent_attribution_enabled: AgentAttributionEnabled {
type: bool,
default: true,
Expand Down
32 changes: 28 additions & 4 deletions app/src/settings_view/warp_agent_page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,13 @@ impl WarpAgentPageView {

let workspace = UserWorkspaces::handle(ctx);
ctx.subscribe_to_model(&workspace, |me, _workspace, event, ctx| {
if let UserWorkspacesEvent::TeamsChanged = event {
// A window moving between teams changes what this page renders (the agent
// attribution toggle resolves its policy from the window's team), and
// `window_team_uids` is not `Tracked`, so autotracking will not repaint it.
if matches!(
event,
UserWorkspacesEvent::TeamsChanged | UserWorkspacesEvent::WindowTeamChanged { .. }
) {
me.sync_custom_endpoint_buttons(ctx);
ctx.notify();
}
Expand Down Expand Up @@ -1994,7 +2000,7 @@ impl WarpAgentPageView {

categories.push(Category::new(
"Agent Attribution",
vec![Box::new(AgentAttributionWidget::default())],
vec![Box::new(AgentAttributionWidget::new(ctx))],
));

#[cfg_attr(not(feature = "local_fs"), allow(unused_mut))]
Expand Down Expand Up @@ -4169,9 +4175,18 @@ pub(crate) fn derive_agent_attribution_toggle_state(
}
}

#[derive(Default)]
struct AgentAttributionWidget {
toggle: SwitchStateHandle,
view_handle: WeakViewHandle<WarpAgentPageView>,
}

impl AgentAttributionWidget {
fn new(ctx: &ViewContext<WarpAgentPageView>) -> Self {
Self {
toggle: Default::default(),
view_handle: ctx.handle(),
}
}
}

impl SettingsWidget for AgentAttributionWidget {
Expand All @@ -4190,7 +4205,16 @@ impl SettingsWidget for AgentAttributionWidget {
let ai_settings = AISettings::as_ref(app);
let is_any_ai_enabled = ai_settings.is_any_ai_enabled(app);

let org_setting = UserWorkspaces::as_ref(app).get_agent_attribution_setting();
// Whether this toggle is locked is live policy, so it is resolved from the window
// being painted rather than captured when the page was built: moving the window to a
// team with a different attribution policy has to change the toggle on the next
// frame. A page rendered outside any window has no team to read, so the user keeps
// control of their own preference.
let workspaces = UserWorkspaces::as_ref(app);
let org_setting = workspaces
.team_context(&self.view_handle, app)
.map(|scope| workspaces.agent_attribution_setting_for_scope(&scope))
.unwrap_or_default();
let state = derive_agent_attribution_toggle_state(
&org_setting,
*ai_settings.agent_attribution_enabled,
Expand Down
88 changes: 59 additions & 29 deletions app/src/terminal/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,37 @@ pub fn get_input_box_top_border_width() -> f32 {
}
}

/// The cloud-mode host `host_selector`'s window should default to: the
/// `WARP_CLOUD_MODE_DEFAULT_HOST` developer override when set, otherwise the default host
/// configured for that window's team.
///
/// Re-run on every team change rather than captured once, so a window that moves to a team
/// with a different self-hosted default picks that up instead of keeping the host it opened
/// with. [`TeamContext`] is the scope that says so: it borrows and expires with the read,
/// where an owned scope would claim this host was pinned to the team it was chosen under.
///
/// Resolved from the host selector's own handle rather than the `Input`'s, because the first
/// read runs while `Input` is still being constructed and a view is absent from
/// `view_to_window` until its own construction finishes. The selector is a completed child by
/// then, so its handle resolves — and reading through the live mapping means the host follows
/// the selector if it is ever moved between windows.
fn effective_default_host(
host_selector: &WeakViewHandle<HostSelector>,
app: &AppContext,
) -> Option<String> {
if let Some(slug) = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
.ok()
.filter(|slug| !slug.is_empty())
{
return Some(slug);
}
let workspaces = UserWorkspaces::as_ref(app);
workspaces
.team_context(host_selector, app)
.and_then(|scope| workspaces.default_host_slug_for_scope(&scope))
.map(String::from)
}

pub const COMPLETIONS_MENU_WIDTH: f32 = 330.;
pub const OPEN_COMPLETIONS_KEYBINDING_NAME: &str = "input:open_completion_suggestions";
pub const INPUT_A11Y_LABEL: &str = "Command Input.";
Expand Down Expand Up @@ -2335,15 +2366,8 @@ impl Input {
) -> ViewHandle<HostSelector> {
let view = ctx
.add_typed_action_view(|ctx| HostSelector::new(menu_positioning_provider.clone(), ctx));
// Env var takes priority over workspace setting for developer testing.
let effective_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
UserWorkspaces::as_ref(ctx)
.default_host_slug()
.map(String::from)
});
let weak_view = view.downgrade();
let effective_host = effective_default_host(&weak_view, ctx);
if let Some(slug) = &effective_host {
view.update(ctx, |selector, ctx| {
selector.set_default_host(slug.clone(), ctx);
Expand Down Expand Up @@ -2376,32 +2400,38 @@ impl Input {
});
}
});
// Keep the host selector and view model in sync when workspace metadata refreshes (e.g.
// admin changes default_host_slug).
// Keep the host selector and view model in sync when the host this window should
// default to changes: because the admin edited the team's `default_host_slug`, or
// because the window moved to a team that configures a different one.
let view_for_ws = view.clone();
let vm_for_ws = view_model.clone();
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), move |_me, _, event, ctx| {
if !matches!(event, UserWorkspacesEvent::TeamsChanged) {
// Windows are independent, so a sibling window switching team must not retarget
// this one.
let affects_this_window = matches!(event, UserWorkspacesEvent::TeamsChanged)
|| matches!(
event,
UserWorkspacesEvent::WindowTeamChanged { window_id }
if *window_id == ctx.window_id()
);
if !affects_this_window {
return;
}
let effective_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
UserWorkspaces::as_ref(ctx)
.default_host_slug()
.map(String::from)
});
if let Some(slug) = &effective_host {
view_for_ws.update(ctx, |selector, ctx| {
selector.set_default_host(slug.clone(), ctx);
});
}
if let Some(slug) = effective_host {
vm_for_ws.update(ctx, |model, _ctx| {
model.set_worker_host(Some(slug));
});
// `None` has to be applied, not skipped: it means the window's team configures no
// self-hosted default, and leaving the previous value in place would keep the
// selector and the run config pointed at another team's worker.
let effective_host = effective_default_host(&weak_view, ctx);
match effective_host.clone() {
Some(slug) => view_for_ws.update(ctx, |selector, ctx| {
selector.set_default_host(slug, ctx);
}),
None => view_for_ws.update(ctx, |selector, ctx| {
selector.clear_default_host(ctx);
}),
}
vm_for_ws.update(ctx, |model, _ctx| {
model.set_worker_host(effective_host);
});
});
view
}
Expand Down
7 changes: 5 additions & 2 deletions app/src/terminal/input/slash_commands/data_source/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,12 +420,15 @@ pub trait SlashCommandDataSource {

fn common_command_gates(&self, ctx: &AppContext) -> CommonCommandGates {
let ai_settings = AISettings::as_ref(ctx);
// Hide /host when no default host is configured (env var or workspace setting).
// Hide /host when no default host is configured (env var or team setting). This data
// source is shared with the TUI and runs on a `ModelContext`, so it has no window to
// scope the read to; it asks the cross-team availability question instead, and the
// command itself resolves the window's own host when it runs.
let has_default_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
.ok()
.filter(|s| !s.is_empty())
.is_some()
|| UserWorkspaces::as_ref(ctx).default_host_slug().is_some();
|| UserWorkspaces::as_ref(ctx).any_team_has_default_host_slug();
CommonCommandGates {
is_orchestration_enabled: ai_settings.is_orchestration_enabled(ctx),
is_cloud_handoff_enabled: ai_settings.is_cloud_handoff_enabled(ctx),
Expand Down
25 changes: 25 additions & 0 deletions app/src/terminal/view/ambient_agent/host_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,31 @@ impl HostSelector {
self.refresh_menu(ctx);
}

/// Drops the default host, e.g. because the window moved to a team that configures none.
///
/// The inverse of [`Self::set_default_host`] and it defers to a saved user selection the
/// same way. Without it, a selection that came only from the previous team's default would
/// survive the move and keep pointing at that team's self-hosted worker.
pub fn clear_default_host(&mut self, ctx: &mut ViewContext<Self>) {
if self.default_host.is_none() {
return;
}
self.default_host = None;

let has_saved_selection = CloudAgentSettings::as_ref(ctx)
.last_selected_host
.value()
.is_some();
if !has_saved_selection {
self.selected = Host::Warp;
let label = self.selected.display_name().to_string();
self.button.update(ctx, |button, ctx| {
button.set_label(label, ctx);
});
}
self.refresh_menu(ctx);
}

/// Programmatically opens the host selector popover. No-op if already open.
pub fn open_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.set_menu_visibility(true, ctx);
Expand Down
Loading
Loading